GNOME Software Plugin Tutorial: GNOME Software Reference Manual

# GNOME Software Plugin Tutorial

GNOME Software is a software installer designed to be easy to use.

## Introduction

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, 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 needs to create a class derived from GsPlugin, and define the vfuncs that it needs. The plugin name is taken automatically from the suffix of the `.so` file. The type of the plugin is exposed to gnome-software using `gs_plugin_query_type()`, which must be exported from the module.

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
    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
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    /*
     * Copyright (C) 2016 Richard Hughes
     */

    #include <glib.h>
    #include <gnome-software.h>

    struct _GsPluginSample {
      GsPlugin parent;

      /* private data here */
    };

    G_DEFINE_TYPE (GsPluginSample, gs_plugin_sample, GS_TYPE_PLUGIN)

    static void
    gs_plugin_sample_init (GsPluginSample *self)
    {
      GsPlugin *plugin = GS_PLUGIN (self);

      gs_plugin_add_rule (plugin, GS_PLUGIN_RULE_RUN_BEFORE, "appstream");
    }

    static void
    gs_plugin_sample_list_apps_async (GsPlugin              *plugin,
                                      GsAppQuery            *query,
                                      GsPluginListAppsFlags  flags,
                                      GCancellable          *cancellable,
                                      GAsyncReadyCallback    callback,
                                      gpointer               user_data)
    {
      GsPluginSample *self = GS_PLUGIN_SAMPLE (plugin);
      g_autoptr(GTask) task = NULL;
      const gchar * const *keywords;
      g_autoptr(GsAppList) list = gs_app_list_new ();

      task = gs_plugin_list_apps_data_new_task (plugin, query, flags,
                                                cancellable, callback, user_data);
      g_task_set_source_tag (task, gs_plugin_sample_list_apps_async);

      if (query == NULL ||
          gs_app_query_get_keywords (query) == NULL ||
          gs_app_query_get_n_properties_set (query) != 1) {
        g_task_return_new_error (task, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
                                 "Unsupported query");
        return;
      }

      keywords = gs_app_query_get_keywords (query);

      for (gsize i = 0; keywords[i] != NULL; i++) {
        if (g_str_equal (keywords[i], "fotoshop")) {
          g_autoptr(GsApp) app = gs_app_new ("gimp.desktop");
          gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
          gs_app_list_add (list, app);
        }
      }

      g_task_return_pointer (task, g_steal_pointer (&list), g_object_unref);
    }

    static GsAppList *
    gs_plugin_sample_list_apps_finish (GsPlugin      *plugin,
                                       GAsyncResult  *result,
                                       GError       **error)
    {
      return g_task_propagate_pointer (G_TASK (result), error);
    }

    static void
    gs_plugin_sample_class_init (GsPluginSampleClass *klass)
    {
      GsPluginClass *plugin_class = GS_PLUGIN_CLASS (klass);

      plugin_class->list_apps_async = gs_plugin_sample_list_apps_async;
      plugin_class->list_apps_finish = gs_plugin_sample_list_apps_finish;
    }

    GType
    gs_plugin_query_type (void)
    {
      return GS_TYPE_PLUGIN_SAMPLE;
    }

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 && \
    cp libgs_plugin_example.so $(pkg-config gnome-software --variable=plugindir)

## Distribution Specific Functionality

Some plugins should only run on specific distributions, for instance the `fedora-pkgdb-collections` plugin should only be used on Fedora 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
    10
    11
    static void
    gs_plugin_sample_init (GsPluginSample *self)
    {
      GsPlugin *plugin = GS_PLUGIN (self);

      if (!gs_plugin_check_distro_id (plugin, "ubuntu")) {
        gs_plugin_set_enabled (plugin, FALSE);
        return;
      }
      /* set up private data etc. */
    }

## Custom Applications in the Installed List

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
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    static void
    gs_plugin_sample_init (GsPluginSample *self)
    {
      GsPlugin *plugin = GS_PLUGIN (self);

      gs_plugin_add_rule (plugin, GS_PLUGIN_RULE_RUN_BEFORE, "icons");
    }

    static void
    gs_plugin_custom_list_apps_async (GsPlugin              *plugin,
                                      GsAppQuery            *query,
                                      GsPluginListAppsFlags  flags,
                                      GCancellable          *cancellable,
                                      GAsyncReadyCallback    callback,
                                      gpointer               user_data)
    {
      g_autofree gchar *fn = NULL;
      g_autoptr(GsApp) app = NULL;
      g_autoptr(GIcon) icon = NULL;
      g_autoptr(GsAppList) list = gs_app_list_new ();
      g_autoptr(GTask) task = NULL;

      task = g_task_new (plugin, cancellable, callback, user_data);
      g_task_set_source_tag (task, gs_plugin_custom_list_apps_async);

      /* We’re only listing installed apps in this example. */
      if (query == NULL ||
          gs_app_query_get_is_installed (query) != GS_APP_QUERY_TRISTATE_TRUE ||
          gs_app_query_get_n_properties_set (query) != 1) {
        g_task_return_new_error (task, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
                                 "Unsupported query");
        return;
      }

      /* check if the app exists */
      fn = g_build_filename (g_get_home_dir (), "chiron", NULL);
      if (!g_file_test (fn, G_FILE_TEST_EXISTS)) {
        g_task_return_pointer (task, g_steal_pointer (&list), g_object_unref);
        return;
      }

      /* the trigger exists, so create a fake app */
      app = gs_app_new ("chiron.desktop");
      gs_app_set_management_plugin (app, plugin);
      gs_app_set_kind (app, AS_COMPONENT_KIND_DESKTOP_APP);
      gs_app_set_state (app, GS_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, GS_SIZE_TYPE_VALID, 2 * 1024 * 1024);
      gs_app_set_size_download (app, GS_SIZE_TYPE_VALID, 3 * 1024 * 1024);
      gs_app_set_origin_hostname (app, "http://www.teaching-example.org/");
      gs_app_add_category (app, "Game");
      gs_app_add_category (app, "ActionGame");
      gs_app_set_license (app, GS_APP_QUALITY_NORMAL, "GPL-2.0-or-later and LGPL-2.1-or-later");

      /* use a stock icon */
      icon = g_themed_icon_new ("input-gaming");
      gs_app_add_icon (app, icon);

      /* return new app */
      gs_app_list_add (list, app);

      g_task_return_pointer (task, g_steal_pointer (&list), g_object_unref);
    }

    static GsAppList *
    gs_plugin_custom_list_apps_finish (GsPlugin      *plugin,
                                       GAsyncResult  *result,
                                       GError       **error)
    {
      return g_task_propagate_pointer (G_TASK (result), error);
    }

This shows a lot of the plugin architecture in action. Some notable points:

- 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_COMPONENT_KIND_DESKTOP_APP` to be visible as an application.

- The origin is where the application originated from — usually this will be something like Fedora Updates.

- Setting the license means we don't get the non-free warning — removing the 3rd party warning can be done using `GS_APP_QUIRK_PROVENANCE`

- The icon will be loaded into a pixbuf of the correct size when needed by the UI. You must ensure that icons are available at common sizes. For icons of type `GsRemoteIcon`, the `icons` plugin will download and cache the icon locally.

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 `refine_async()` 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.

## Downloading Metadata and Updates

The plugin loader supports a `refresh_metadata_async()` 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 `refresh_metadata_async()` 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 a 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_get_updates()` method is called, and then a `GsPluginJobUpdateApps` job may be run with the `GS_PLUGIN_JOB_UPDATE_APPS_FLAGS_NO_APPLY` flag if the user has configured automatic updates. 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 `refresh_metadata_async()` and then downloads the large firmware files themselves when `update_apps_async()` is called.

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 `refresh_metadata_async()` 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
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    static void progress_cb (gsize bytes_downloaded,
                             gsize total_download_size,
                             gpointer user_data);
    static void download_file_cb (GObject *source_object,
                                  GAsyncResult *result,
                                  gpointer user_data);

    static void
    gs_plugin_example_refresh_metadata_async (GsPlugin *plugin,
                                              guint64 cache_age_secs,
                                              GsPluginRefreshMetadataFlags flags,
                                              GCancellable *cancellable,
                                              GError **error)
    {
      const gchar *metadata_filename = "/var/cache/example/metadata.xml";
      const gchar *metadata_url = "https://www.example.com/new.xml";
      g_autoptr(GFile) file = g_file_new_for_path (metadata_filename);
      g_autoptr(GTask) task = NULL;
      g_autoptr(SoupSession) soup_session = NULL;

      task = g_task_new (plugin, cancellable, callback, user_data);
      g_task_set_source_tag (task, gs_plugin_example_refresh_metadata_async);

      soup_session = gs_build_soup_session ();

      /* is the metadata missing or too old? */
      if (gs_utils_get_file_age (file) > cache_age_secs) {
        gs_download_file_async (soup_session,
                                metadata_url,
                                file,
                                G_PRIORITY_LOW,
                                progress_cb,
                                plugin,
                                cancellable,
                                download_file_cb,
                                g_steal_pointer (&task));
        return;
      }

      g_task_return_boolean (task, TRUE);
    }

    static void
    progress_cb (gsize bytes_downloaded,
                 gsize total_download_size,
                 gpointer user_data)
    {
      g_debug ("Downloaded %zu of %zu bytes", bytes_downloaded, total_download_size);
    }

    static void
    download_file_cb (GObject *source_object,
                      GAsyncResult *result,
                      gpointer user_data)
    {
      GsPlugin *plugin = GS_PLUGIN (source_object);
      g_autoptr(GTask) task = g_steal_pointer (&user_data);

      if (!gs_download_file_finish (result, &local_error)) {
        g_task_return_error (task, g_steal_pointer (&local_error));
      } else {
        g_debug ("successfully downloaded new metadata");
        g_task_return_boolean (task, TRUE);
      }
    }

    static gboolean
    gs_plugin_example_refresh_metadata_finish (GsPlugin *plugin,
                                               GAsyncResult *result,
                                               GError **error)
    {
      return g_task_propagate_boolean (G_TASK (result), error);
    }

## Adding Application Information Using Refine

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
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    static void
    gs_plugin_example_refine_async (GsPlugin *plugin,
                                    GsAppList *list,
                                    GsPluginRefineFlags flags,
                                    GCancellable *cancellable,
                                    GAsyncReadyCallback callback,
                                    gpointer user_data)
    {
      g_autoptr(GTask) task = NULL;

      task = g_task_new (plugin, cancellable, callback, user_data);
      g_task_set_source_tag (task, gs_plugin_example_refine_async);

      /* not required */
      if ((flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE) == 0) {
        g_task_return_boolean (task, TRUE);
        return;
      }

      for (guint i = 0; i < gs_app_list_length (list); i++) {
        GsApp *app = gs_app_list_index (list, i);

        /* already set */
        if (gs_app_get_license (app) != NULL) {
          g_task_return_boolean (task, TRUE);
          return;
        }

        /* 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.1-or-later");
      }

      g_task_return_boolean (task, TRUE);
    }

    static gboolean
    gs_plugin_example_refine_finish (GsPlugin *plugin,
                                     GAsyncResult *result,
                                     GError **error)
    {
      return g_task_propagate_boolean (G_TASK (result), error);
    }

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.

## Adopting AppStream Applications

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/swcatalog/xml/`, `/var/cache/swcatalog/xml/` or `~/.local/share/swcatalog/xml/`. 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 `flatpak` plugin downloads AppStream data for configured remotes during `refresh_metadata_async()`.

The only extra step a plugin providing its own apps needs to do is to implement the `GsPluginClass.adopt_app()` virtual 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.

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
    7
    8
    9
    10
    11
    12
    13
    14
    void
    gs_plugin_sample_adopt_app (GsPlugin *plugin, GsApp *app)
    {
      if (gs_app_get_kind (app) == AS_COMPONENT_KIND_FIRMWARE)
        gs_app_set_management_plugin (app, plugin);
    }

    static void
    gs_plugin_sample_class_init (GsPluginSampleClass *klass)
    {
      GsPluginClass *plugin_class = GS_PLUGIN_CLASS (klass);

      plugin_class->adopt_app = gs_plugin_sample_adopt_app;
    }

## Using The Plugin Cache

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 `list_apps()` 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.