GNOME Software is a software installer designed to be easy to use.
At the heart of gnome software the application is just a plugin loader that has some GTK UI that gets created for various result types. The idea is we have lots of small plugins that each do one thing and then pass the result onto the other plugins. These are ordered by dependencies against each other at runtime and each one can do things like editing an existing application or adding a new application to the result set. This is how we can add support for things like firmware updating, Steam, GNOME Shell web-apps and flatpak bundles without making big changes all over the source tree.
There are broadly 3 types of plugin methods:
Actions: Do something on a specific GsApp
Refine: Get details about a specific GsApp
Adopt: Can this plugin handle this GsApp
In general, building things out-of-tree isn't something that I think is a very good idea; the API and ABI inside gnome-software is still changing and there's a huge benefit to getting plugins upstream where they can undergo review and be ported as the API adapts. I'm also super keen to provide configurability in GSettings for doing obviously-useful things, the sort of thing Fleet Commander can set for groups of users.
However, now we're shipping gnome-software in enterprise-class distros we might want to allow customers to ship their own plugins to make various business-specific changes that don't make sense upstream. This might involve querying a custom LDAP server and changing the suggested apps to reflect what groups the user is in, or might involve showing a whole new class of applications that does not conform to the Linux-specific application is a desktop-file paradigm. This is where a plugin makes sense.
The plugin only needs to define the vfuncs that are required, and the
plugin name is taken automatically from the suffix of the
.so
file.
Example 1. A sample plugin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
/* * Copyright (C) 2016 Richard Hughes */ #include <gnome-software.h> void gs_plugin_initialize (GsPlugin *plugin) { gs_plugin_add_rule (plugin, GS_PLUGIN_RULE_RUN_BEFORE, "appstream"); } gboolean gs_plugin_add_search (GsPlugin *plugin, gchar **values, GsAppList *list, GCancellable *cancellable, GError **error) { guint i; for (i = 0; values[i] != NULL; i++) { if (g_strcmp0 (values[i], "fotoshop") == 0) { g_autoptr(GsApp) app = gs_app_new ("gimp.desktop"); gs_app_add_quirk (app, AS_APP_QUIRK_MATCH_ANY_PREFIX); gs_app_list_add (list, app); } } return TRUE; } |
We have to define when our plugin is run in reference to other plugins,
in this case, making sure we run before appstream
.
As we're such a simple plugin we're relying on another plugin to run
after us to actually make the GsApp complete,
i.e. loading icons and setting a localised long description.
In this example we want to show GIMP as a result (from any provider,
e.g. flatpak or a distro package) when the user searches exactly for
fotoshop
.
We can then build and install the plugin using:
1 2 3 4 |
gcc -shared -o libgs_plugin_example.so gs-plugin-example.c -fPIC \ `pkg-config --libs --cflags gnome-software` \ -DI_KNOW_THE_GNOME_SOFTWARE_API_IS_SUBJECT_TO_CHANGE && sudo cp libgs_plugin_example.so `pkg-config gnome-software --variable=plugindir` |
Some plugins should only run on specific distributions, for instance
the ubuntu-reviews
plugin should only be used on Ubuntu
systems.
This can be achieved with a simple runtime check using the helper
gs_plugin_check_distro_id()
method or the GsOsRelease
object where more complicated rules are required.
Example 2. Self disabling on other distributions
1 2 3 4 5 6 7 8 9 |
void gs_plugin_initialize (GsPlugin *plugin) { if (!gs_plugin_check_distro_id (plugin, "ubuntu")) { gs_plugin_set_enabled (plugin, FALSE); return; } /* allocate private data etc. */ } |
Next is returning custom applications in the installed list. The use case here is a proprietary software distribution method that installs custom files into your home directory, but you can use your imagination for how this could be useful. The example here is all hardcoded, and a true plugin would have to derive the details about the GsApp, for example reading in an XML file or YAML config file somewhere.
Example 3. Example showing a custom installed application
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
#include <gnome-software.h> void gs_plugin_initialize (GsPlugin *plugin) { gs_plugin_add_rule (plugin, GS_PLUGIN_RULE_RUN_BEFORE, "icons"); } gboolean gs_plugin_add_installed (GsPlugin *plugin, GsAppList *list, GCancellable *cancellable, GError **error) { g_autofree gchar *fn = NULL; g_autoptr(GsApp) app = NULL; g_autoptr(AsIcon) icon = NULL; /* check if the app exists */ fn = g_build_filename (g_get_home_dir (), "chiron", NULL); if (!g_file_test (fn, G_FILE_TEST_EXISTS)) return TRUE; /* the trigger exists, so create a fake app */ app = gs_app_new ("example:chiron.desktop"); gs_app_set_management_plugin (app, "example"); gs_app_set_kind (app, AS_APP_KIND_DESKTOP); gs_app_set_state (app, AS_APP_STATE_INSTALLED); gs_app_set_name (app, GS_APP_QUALITY_NORMAL, "Chiron"); gs_app_set_summary (app, GS_APP_QUALITY_NORMAL, "A teaching application"); gs_app_set_description (app, GS_APP_QUALITY_NORMAL, "Chiron is the name of an application.\n\n" "It can be used to demo some of our features"); /* these are all optional, but make details page looks better */ gs_app_set_version (app, "1.2.3"); gs_app_set_size_installed (app, 2 * 1024 * 1024); gs_app_set_size_download (app, 3 * 1024 * 1024); gs_app_set_origin_ui (app, "The example plugin"); gs_app_add_category (app, "Game"); gs_app_add_category (app, "ActionGame"); gs_app_add_kudo (app, GS_APP_KUDO_INSTALLS_USER_DOCS); gs_app_set_license (app, GS_APP_QUALITY_NORMAL, "GPL-2.0+ and LGPL-2.1+"); /* create a stock icon which will be loaded by the 'icons' plugin */ icon = as_icon_new (); as_icon_set_kind (icon, AS_ICON_KIND_STOCK); as_icon_set_name (icon, "input-gaming"); gs_app_add_icon (app, icon); /* return new app */ gs_app_list_add (list, app); return TRUE; } |
This shows a lot of the plugin architecture in action. Some notable points:
The application ID (
example:chiron.desktop
) has a prefix ofexample
which means we can co-exist with any package or flatpak version of the Chiron application, not setting the prefix would make the UI confused if more than onechiron.desktop
got added.Setting the management plugin means we can check for this string when working out if we can handle the install or remove action.
Most applications want a kind of
AS_APP_KIND_DESKTOP
to be visible as an application.The origin is where the application originated from -- usually this will be something like Fedora Updates.
The
GS_APP_KUDO_INSTALLS_USER_DOCS
means we get the blue "Documentation" award in the details page; there are many kudos to award to deserving apps.Setting the license means we don't get the non-free warning -- removing the 3rd party warning can be done using
AS_APP_QUIRK_PROVENANCE
The
icons
plugin will take the stock icon and convert it to a pixbuf of the correct size.
To show this fake application just compile and install the plugin,
touch ~/chiron
and then restart gnome-software.
To avoid restarting gnome-software
each time a
proper plugin would create a GFileMonitor
object to
monitor files.
By filling in the optional details (which can also be filled in using
gs_plugin_refine_app()
you can also make the details
page a much more exciting place.
Adding a set of screenshots is left as an exercise to the reader.
The plugin loader supports a gs_plugin_refresh()
vfunc that
is called in various situations.
To ensure plugins have the minimum required metadata on disk it is
called at startup, but with a cache age of infinite.
This basically means the plugin must just ensure that
any data exists no matter what the age.
Usually once per hour, we'll call gs_plugin_refresh()
but
with the correct cache age set (typically a little over 24 hours) which
allows the plugin to download new metadata or payload files from remote
servers.
The gs_utils_get_file_age()
utility helper can help you
work out the cache age of file, or the plugin can handle it some other
way.
For the Flatpak plugin we just make sure the AppStream metadata exists
at startup, which allows us to show search results in the UI.
If the metadata did not exist (e.g. if the user had added a remote
using the command-line without gnome-software running) then we would
show a loading screen with a progress bar before showing the main UI.
On fast connections we should only show that for a couple of seconds,
but it's a good idea to try any avoid that if at all possible in the
plugin.
Once per day the gs_plugin_refresh()
method is called again,
but this time with GS_PLUGIN_REFRESH_FLAGS_PAYLOAD
set.
This is where the Flatpak plugin would download any ostree trees (but
not doing the deploy step) so that the applications can be updated live
in the details panel without having to wait for the download to complete.
In a similar way, the fwupd plugin downloads the tiny LVFS metadata with
GS_PLUGIN_REFRESH_FLAGS_METADATA
and then downloads the
large firmware files themselves only when the
GS_PLUGIN_REFRESH_FLAGS_PAYLOAD
flag is set.
If the @app
parameter is set for
gs_plugin_download_file()
then the progress of the download
is automatically proxied to the UI elements associated with the
application, for instance the install button would show a progress bar
in the various different places in the UI.
For a refresh there's no relevant GsApp to use, so we'll leave it
NULL
which means something is happening globally which the
UI can handle how it wants, for instance showing a loading page at startup.
Note, if the downloading fails it's okay to return FALSE
;
the plugin loader continues to run all plugins and just logs an error
to the console. We'll be calling into gs_plugin_refresh()
again in only another hour, so there's no need to bother the user.
For actions like gs_plugin_app_install
we also do the same
thing, but we also save the error on the GsApp itself so that the UI is
free to handle that how it wants, for instance showing a GtkDialog
window for example.
Example 4. Refresh example
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
gboolean gs_plugin_refresh (GsPlugin *plugin, guint cache_age, GsPluginRefreshFlags flags, GCancellable *cancellable, GError **error) { const gchar *metadata_fn = "/var/cache/example/metadata.xml"; const gchar *metadata_url = "http://www.example.com/new.xml"; /* this is called at startup and once per day */ if (flags & GS_PLUGIN_REFRESH_FLAGS_METADATA) { g_autoptr(GFile) file = g_file_new_for_path (metadata_fn); /* is the metadata missing or too old */ if (gs_utils_get_file_age (file) > cache_age) { if (!gs_plugin_download_file (plugin, NULL, metadata_url, metadata_fn, cancellable, error)) { /* it's okay to fail here */ return FALSE; } g_debug ("successfully downloaded new metadata"); } } /* this is called when the session is idle */ if ((flags & GS_PLUGIN_REFRESH_FLAGS_PAYLOAD) == 0) { // FIXME: download any required updates now } return TRUE; } |
As previous examples have shown it's very easy to add a new application to the search results, updates list or installed list. Some plugins don't want to add more applications, but want to modify existing applications to add more information depending on what is required by the UI code. The reason we don't just add everything at once is that for search-as-you-type to work effectively we need to return results in less than about 50ms and querying some data can take a long time. For example, it might take a few hundred ms to work out the download size for an application when a plugin has to also look at what dependencies are already installed. We only need this information once the user has clicked the search results and when the user is in the details panel, so we can save a ton of time not working out properties that are not useful.
Example 5. Refine example
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
gboolean gs_plugin_refine_app (GsPlugin *plugin, GsApp *app, GsPluginRefineFlags flags, GCancellable *cancellable, GError **error) { /* not required */ if ((flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE) == 0) return TRUE; /* already set */ if (gs_app_get_license (app) != NULL) return TRUE; /* FIXME, not just hardcoded! */ if (g_strcmp0 (gs_app_get_id (app, "chiron.desktop") == 0)) gs_app_set_license (app, "GPL-2.0 and LGPL-2.0+"); return TRUE; } |
This is a simple example, but shows what a plugin needs to do.
It first checks if the action is required, in this case
GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE
.
This request is more common than you might expect as even the search
results shows a non-free label if the license is unspecified or
non-free.
It then checks if the license is already set, returning with success
if so.
If not, it checks the application ID and hardcodes a license; in the
real world this would be querying a database or parsing an additional
config file.
As mentioned before, if the license value is freely available without
any extra work then it's best just to set this at the same time as
when adding the app with gs_app_list_add()
.
Think of refine as adding things that cost time to
calculate only when really required.
The UI in gnome-software is quite forgiving for missing data, hiding
sections or labels as required.
Some things are required however, and forgetting to assign an icon or
short description will get the application vetoed so that it's not
displayed at all.
Helpfully, running gnome-software --verbose
on the
command line will tell you why an application isn't shown along with
any extra data.
There's a lot of flexibility in the gnome-software plugin structure;
a plugin can add custom applications and handle things like search and
icon loading in a totally custom way.
Most of the time you don't care about how search is implemented or how
icons are going to be loaded, and you can re-use a lot of the existing
code in the appstream
plugin.
To do this you just save an AppStream-format XML file in either
/usr/share/app-info/xmls/
,
/var/cache/app-info/xmls/
or
~/.local/share/app-info/xmls/
.
GNOME Software will immediately notice any new files, or changes to
existing files as it has set up the various inotify watches.
This allows plugins to care a lot less about how applications are
going to be shown.
For example, the steam
plugin downloads and parses the
descriptions from a remote service during
gs_plugin_refresh()
, and also finds the best icon types
and downloads them too.
Then it exports the data to an AppStream XML file, saving it to your
home directory.
This allows all the applications to be easily created (and then refined)
using something as simple as gs_app_new("steam:foo.desktop")
.
All the search tokenisation and matching is done automatically,
so it makes the plugin much simpler and faster.
The only extra step the steam plugin needs to do is implement the
gs_plugin_adopt_app()
function.
This is called when an application does not have a management plugin
set, and allows the plugin to claim the
application for itself so it can handle installation, removal and
updating.
In the case of steam it could check the ID has a prefix of
steam:
or could check some other plugin-specific metadata
using gs_app_get_metadata_item()
.
Another good example is the fwupd
that wants to handle
any firmware we've discovered in the AppStream XML.
This might be shipped by the vendor in a package using Satellite,
or downloaded from the LVFS. It wouldn't be kind to set a management
plugin explicitly in case XFCE or KDE want to handle this in a
different way. This adoption function in this case is trivial:
1 2 3 4 5 6 |
void gs_plugin_adopt_app (GsPlugin *plugin, GsApp *app) { if (gs_app_get_kind (app) == AS_APP_KIND_FIRMWARE) gs_app_set_management_plugin (app, "fwupd"); } |
GNOME Software used to provide a per-process plugin cache, automatically de-duplicating applications and trying to be smarter than the plugins themselves. This involved merging applications created by different plugins and really didn't work very well. For versions 3.20 and later we moved to a per-plugin cache which allows the plugin to control getting and adding applications to the cache and invalidating it when it made sense. This seems to work a lot better and is an order of magnitude less complicated. Plugins can trivially be ported to using the cache using something like this:
1 2 3 4 5 6 7 8 |
/* create new object */ id = gs_plugin_flatpak_build_id (inst, xref); - app = gs_app_new (id); + app = gs_plugin_cache_lookup (plugin, id); + if (app == NULL) { + app = gs_app_new (id); + gs_plugin_cache_add (plugin, id, app); + } |
Using the cache has two main benefits for plugins.
The first is that we avoid creating duplicate GsApp objects for the
same logical thing.
This means we can query the installed list, start installing an
application, then query it again before the install has finished.
The GsApp returned from the second add_installed()
request will be the same GObject, and thus all the signals connecting
up to the UI will still be correct.
This means we don't have to care about migrating
the UI widgets as the object changes and things like progress bars just
magically work.
The other benefit is more obvious.
If we know the application state from a previous request we don't have
to query a daemon or do another blocking library call to get it.
This does of course imply that the plugin is properly invalidating
the cache using gs_plugin_cache_invalidate()
which it
should do whenever a change is detected.
Whether a plugin uses the cache for this reason is up to the plugin,
but if it does it is up to the plugin to make sure the cache doesn't
get out of sync.