diff options
Diffstat (limited to '')
-rw-r--r-- | plugins/eos-updater/com.endlessm.Updater.xml | 292 | ||||
-rw-r--r-- | plugins/eos-updater/gs-plugin-eos-updater.c | 1134 | ||||
-rw-r--r-- | plugins/eos-updater/gs-plugin-eos-updater.h | 22 | ||||
-rw-r--r-- | plugins/eos-updater/meson.build | 25 | ||||
-rw-r--r-- | plugins/eos-updater/tests/eos_updater.py | 414 | ||||
-rwxr-xr-x | plugins/eos-updater/tests/manual-test.py | 434 |
6 files changed, 2321 insertions, 0 deletions
diff --git a/plugins/eos-updater/com.endlessm.Updater.xml b/plugins/eos-updater/com.endlessm.Updater.xml new file mode 100644 index 0000000..641c7f4 --- /dev/null +++ b/plugins/eos-updater/com.endlessm.Updater.xml @@ -0,0 +1,292 @@ +<!DOCTYPE node PUBLIC +'-//freedesktop//DTD D-BUS Object Introspection 1.0//EN' +'http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd'> +<node> + <!-- + com.endlessm.Updater: + @short_description: Endless OS updater control interface + + Interface to query the state of the OS updater, and to control it: checking + for, downloading and applying updates. + + The updater moves through a series of states (see the `State` property and + `StateChanged` signal), with state transitions triggered by the appropriate + method call, or by an error in an operation. It will return a + `com.endlessm.Updater.Error.WrongState` error if you attempt to perform an + invalid state transition. + --> + <interface name="com.endlessm.Updater"> + <!-- + Poll: + + Check for updates. This may be performed from the `Ready`, + `UpdateAvailable`, `UpdateReady` or `Error` states. It will immediately + transition to the `Polling` state. If an update is then successfully + found, it will transition to the `UpdateAvailable` state. If no update is + available, it will transition to the `Ready` state. If there is an error + checking for an update, it will transition to the `Error` state. + + If an update is found, its details will be reported through the updater’s + properties once the updater reaches the `UpdateAvailable` state. In + particular, through the `UpdateID`, `UpdateRefspec`, `UpdateLabel` and + `UpdateMessage` properties. + --> + <method name="Poll"></method> + + <!-- + PollVolume: + @path: Absolute path to the root directory of a volume to check. + + Like `Poll`, except this polls a specific removable volume (such as a USB + stick), rather than the internet. + + If a `.ostree/repo` directory is available beneath @path, and it contains + a valid OSTree repository, that repository will be checked for updates. + If no such directory exists, the updater will transition to state `Ready` + as no update is available. + --> + <method name="PollVolume"> + <arg name="path" type="s" direction="in"/> + </method> + + <!-- + Fetch: + + Download an update. This may be performed from the `UpdateAvailable` state + only. It will immediately transition to the `Fetching` state. If the + update is successfully downloaded, it will transition to the `UpdateReady` + state; if there was an error it will transition to `Error`. + + Download progress will be reported through the updater’s properties; in + particular, `DownloadedBytes`. + --> + <method name="Fetch"></method> + + <!-- + FetchFull: + @options: Potentially empty dictionary of options. + + Like `Fetch`, except options may be provided to affect its behaviour. + Currently, the following options are supported (unsupported options are + ignored): + + * `force` (type: `b`): If true, force the download without scheduling + it through the system’s metered data scheduler. Typically, this would + be true in response to an explicit user action, and false otherwise. + * `scheduling-timeout-seconds` (type: `u`): Number of seconds to wait + for permission to download from the system’s metered data scheduler, + before returning a `com.endlessm.Updater.Error.MeteredConnection` + error and cancelling the download. Pass zero to disable the timeout. + --> + <method name="FetchFull"> + <arg name="options" type="a{sv}" direction="in"/> + </method> + + <!-- + Apply: + + Apply a downloaded update so that it’s available to boot into on the next + boot. This may be performed from the `UpdateReady` state only. It will + immediately transition to the `Applying` state. If the update is + successfully applied, it will transition to the `UpdateApplied` state; if + there was an error it will transition to `Error`. + --> + <method name="Apply"></method> + + <!-- + Cancel: + + Cancel the ongoing poll, fetch or apply operation. This may be performed + from the `Polling`, `Fetching` or `ApplyingUpdate` states. It will cancel + the operation then transition to the `Error` state, with the error + `com.endlessm.Updater.Error.Cancelled`. + --> + <method name="Cancel"></method> + + <!-- + State: + + Current state of the updater. This will be one of the following: + + * `0` (`None`): No state. + * `1` (`Ready`): Ready to perform an action. + * `2` (`Error`): An error occurred. See the `ErrorName` and + `ErrorMessage` properties for details. + * `3` (`Polling`): Checking for updates. + * `4` (`UpdateAvailable`): An update is available. See the `UpdateID`, + `UpdateRefspec`, `UpdateLabel` and `UpdateMessage` properties for + details. + * `5` (`Fetching`): Downloading an update. See the `DownloadedBytes` + property for progress updates. + * `6` (`UpdateReady`): Update downloaded and ready to apply. + * `7` (`ApplyingUpdate`): Applying an update. + * `8` (`UpdateApplied`): Update applied and ready to reboot into. + + State changes are notified using the `StateChanged` signal. + --> + <property name="State" type="u" access="read"/> + + <!-- + UpdateID: + + Checksum of the OSTree commit available as an update, or the empty string + if no update is available. + --> + <property name="UpdateID" type="s" access="read"/> + + <!-- + UpdateRefspec: + + Refspec (remote name and branch name) of the OSTree commit available as an + update, or the empty string if no update is available. + --> + <property name="UpdateRefspec" type="s" access="read"/> + + <!-- + OriginalRefspec: + + Refspec (remote name and branch name) of the currently booted OSTree + commit. + --> + <property name="OriginalRefspec" type="s" access="read"/> + + <!-- + CurrentID: + + Checksum of the currently booted OSTree commit. + --> + <property name="CurrentID" type="s" access="read"/> + + <!-- + UpdateLabel: + + Subject of the OSTree commit available as an update, or the empty string + if it’s not set or if no update is available. This is the title of the + update. + --> + <property name="UpdateLabel" type="s" access="read"/> + + <!-- + UpdateMessage: + + Description body of the OSTree commit available as an update, or the empty + string if it’s not set or if no update is available. This is the + description of the update. + --> + <property name="UpdateMessage" type="s" access="read"/> + + <!-- + Version: + + Version number of the OSTree commit available as an update, or the empty + string if it’s not set or if no update is available. + --> + <property name="Version" type="s" access="read"/> + + <!-- + DownloadSize: + + Size (in bytes) of the update when downloaded, or `-1` if an update is + available but its download size is unknown. `0` if no update is available. + --> + <property name="DownloadSize" type="x" access="read"/> + + <!-- + DownloadedBytes: + + Number of bytes of the update which have already been downloaded. This + will be `0` before a download starts, and could be `-1` if the + `DownloadSize` is unknown. + --> + <property name="DownloadedBytes" type="x" access="read"/> + + <!-- + UnpackedSize: + + Size (in bytes) of the update when unpacked, or `-1` if an update is + available but its unpacked size is unknown. `0` if no update is available. + --> + <property name="UnpackedSize" type="x" access="read"/> + + <!-- + FullDownloadSize: + + Version of `DownloadSize` which also includes the sizes of parts of the + update which are already present locally (and hence which don’t need to + be downloaded again). + --> + <property name="FullDownloadSize" type="x" access="read"/> + + <!-- + FullUnpackedSize: + + Version of `UnpackedSize` which also includes the sizes of parts of the + update which are already unpacked locally (and hence which won’t occupy + further disk space once the update is applied). + --> + <property name="FullUnpackedSize" type="x" access="read"/> + + <!-- + ErrorCode: + + Error code of the current error, or `0` if no error has been reported. + This is in an unspecified error doman, and hence is useless. + + Deprecated: Use `ErrorName` instead. + --> + <property name="ErrorCode" type="u" access="read"> + <annotation name="org.freedesktop.DBus.Deprecated" value="true"/> + </property> + + <!-- + ErrorName: + + A fully-qualified D-Bus error name, as might be returned from a D-Bus + method. + + This is the empty string if no error has been reported. + + Known errors include: + + * `com.endlessm.Updater.Error.WrongState`: Method was called in a state + which doesn’t support that method. + * `com.endlessm.Updater.Error.LiveBoot`: The updater cannot be used + because the current system is a live boot. + * `com.endlessm.Updater.Error.WrongConfiguration`: A configuration file + contains an error. + * `com.endlessm.Updater.Error.NotOstreeSystem`: The updater cannot be + used because the current system is not booted from an OSTree commit. + * `com.endlessm.Updater.Error.Fetching`: Error when downloading an + update. + * `com.endlessm.Updater.Error.MalformedAutoinstallSpec`: An autoinstall + specification in the pending update contains an error. + * `com.endlessm.Updater.Error.UnknownEntryInAutoinstallSpec`: An + autoinstall specification in the pending update contains an unknown + entry. + * `com.endlessm.Updater.Error.FlatpakRemoteConflict`: An autoinstall + specification in the pending update contains a remote name which + doesn’t match the system’s configuration. + * `com.endlessm.Updater.Error.MeteredConnection`: A fetch operation timed + out while waiting for permission to download. + --> + <property name="ErrorName" type="s" access="read"/> + + <!-- + ErrorMessage: + + A human-readable (but unlocalised) error message, or the empty string if + no error has been reported. + --> + <property name="ErrorMessage" type="s" access="read"/> + + <!-- + StateChanged: + @state: The new state. + + Signal notifying of a change in the `State` property. + --> + <signal name="StateChanged"> + <arg type="u" name="state"/> + </signal> + </interface> +</node> diff --git a/plugins/eos-updater/gs-plugin-eos-updater.c b/plugins/eos-updater/gs-plugin-eos-updater.c new file mode 100644 index 0000000..e4c6260 --- /dev/null +++ b/plugins/eos-updater/gs-plugin-eos-updater.c @@ -0,0 +1,1134 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016-2019 Endless Mobile, Inc + * + * Authors: + * Joaquim Rocha <jrocha@endlessm.com> + * Philip Withnall <withnall@endlessm.com> + * + * Licensed under the GNU General Public License Version 2 + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include <config.h> +#include <gio/gio.h> +#include <glib.h> +#include <glib/gi18n.h> +#include <glib-object.h> +#include <gnome-software.h> +#include <gs-plugin.h> +#include <gs-utils.h> +#include <math.h> +#include <ostree.h> + +#include "gs-eos-updater-generated.h" +#include "gs-plugin-eos-updater.h" +#include "gs-plugin-private.h" + +/* + * SECTION: + * Plugin to poll for, download and apply OS updates using the `eos-updater` + * service when running on Endless OS. + * + * This plugin is only useful on Endless OS. + * + * It creates a proxy for the `eos-updater` D-Bus service, which implements a + * basic state machine which progresses through several states in order to + * download updates: `Ready` (doing nothing) → `Poll` (checking for updates) → + * `Fetch` (downloading an update) → `Apply` (deploying the update’s OSTree, + * before a reboot). Any state may transition to the `Error` state at any time, + * and the daemon may disappear at any time. + * + * This plugin follows the state transitions signalled by the daemon, and + * updates the state of a single #GsApp instance (`os_upgrade`) to reflect the + * OS upgrade in the UI. + * + * Calling gs_plugin_eos_updater_refresh_metadata_async() will result in this + * plugin calling the `Poll()` method on the `eos-updater` daemon to check for a + * new update. + * + * Calling gs_plugin_app_upgrade_download() will result in this plugin calling + * a sequence of methods on the `eos-updater` daemon to check for, download and + * apply an update. Typically, gs_plugin_app_upgrade_download() should be called + * once `eos-updater` is already in the `UpdateAvailable` state. It will report + * progress information, with the first 75 percentage points of the progress + * reporting the download progress, and the final 25 percentage points reporting + * the OSTree deployment progress. The final 25 percentage points are currently + * faked because we can’t get reasonable progress data out of OSTree. + * + * The proxy object (`updater_proxy`) uses the thread-default main context from + * the gs_plugin_eos_updater_setup() function, which is currently the global default main + * context from gnome-software’s main thread. This means all the signal + * callbacks from the proxy will be executed in the main thread, and *must not + * block*. + * + * Asynchronous plugin vfuncs (such as + * gs_plugin_eos_updater_refresh_metadata_async()) are run in gnome-software’s + * main thread and *must not block*. As they all call D-Bus methods, the work + * they do is minimal and hence is OK to happen in the main thread. + * + * The other functions (gs_plugin_app_upgrade_download(), + * etc.) are called in #GTask worker threads. They are allowed to call methods + * on the proxy; the main thread is only allowed to receive signals and check + * properties on the proxy, to avoid blocking. Consequently, worker threads need + * to block on the main thread receiving state change signals from + * `eos-updater`. Receipt of these signals is notified through + * `state_change_cond`. This means that all functions which access + * `GsPluginEosUpdater` must lock it using the `mutex`. + * + * `updater_proxy`, `os_upgrade` and `cancellable` are only set in + * gs_plugin_eos_updater_setup(), and are both internally thread-safe — so they can both be + * dereferenced and have their methods called from any thread without + * necessarily holding `mutex`. + * + * Cancellation of any operations on the `eos-updater` daemon (polling, fetching + * or applying) is implemented by calling the `Cancel()` method on it. This is + * permanently connected to the private `cancellable` #GCancellable instance, + * which persists for the lifetime of the plugin. The #GCancellable instances + * for various operations can be temporarily chained to it for the duration of + * each operation. + * + * FIXME: Once all methods are made asynchronous, the locking can be dropped + * from this plugin. + */ + +static const guint max_progress_for_update = 75; /* percent */ + +typedef enum { + EOS_UPDATER_STATE_NONE = 0, + EOS_UPDATER_STATE_READY, + EOS_UPDATER_STATE_ERROR, + EOS_UPDATER_STATE_POLLING, + EOS_UPDATER_STATE_UPDATE_AVAILABLE, + EOS_UPDATER_STATE_FETCHING, + EOS_UPDATER_STATE_UPDATE_READY, + EOS_UPDATER_STATE_APPLYING_UPDATE, + EOS_UPDATER_STATE_UPDATE_APPLIED, +} EosUpdaterState; +#define EOS_UPDATER_N_STATES (EOS_UPDATER_STATE_UPDATE_APPLIED + 1) + +static const gchar * +eos_updater_state_to_str (EosUpdaterState state) +{ + const gchar * const eos_updater_state_str[] = { + "None", + "Ready", + "Error", + "Polling", + "UpdateAvailable", + "Fetching", + "UpdateReady", + "ApplyingUpdate", + "UpdateApplied", + }; + + G_STATIC_ASSERT (G_N_ELEMENTS (eos_updater_state_str) == EOS_UPDATER_N_STATES); + + g_return_val_if_fail ((gint) state < EOS_UPDATER_N_STATES, "unknown"); + return eos_updater_state_str[state]; +} + +static void +gs_eos_updater_error_convert (GError **perror) +{ + GError *error = perror != NULL ? *perror : NULL; + + /* not set */ + if (error == NULL) + return; + + /* parse remote eos-updater error */ + if (g_dbus_error_is_remote_error (error)) { + g_autofree gchar *remote_error = g_dbus_error_get_remote_error (error); + + g_dbus_error_strip_remote_error (error); + + if (g_str_equal (remote_error, "com.endlessm.Updater.Error.WrongState")) { + error->code = GS_PLUGIN_ERROR_FAILED; + } else if (g_str_equal (remote_error, "com.endlessm.Updater.Error.LiveBoot") || + g_str_equal (remote_error, "com.endlessm.Updater.Error.NotOstreeSystem") || + g_str_equal (remote_error, "org.freedesktop.DBus.Error.ServiceUnknown")) { + error->code = GS_PLUGIN_ERROR_NOT_SUPPORTED; + } else if (g_str_equal (remote_error, "com.endlessm.Updater.Error.WrongConfiguration")) { + error->code = GS_PLUGIN_ERROR_FAILED; + } else if (g_str_equal (remote_error, "com.endlessm.Updater.Error.Fetching")) { + error->code = GS_PLUGIN_ERROR_DOWNLOAD_FAILED; + } else if (g_str_equal (remote_error, "com.endlessm.Updater.Error.MalformedAutoinstallSpec") || + g_str_equal (remote_error, "com.endlessm.Updater.Error.UnknownEntryInAutoinstallSpec") || + g_str_equal (remote_error, "com.endlessm.Updater.Error.FlatpakRemoteConflict")) { + error->code = GS_PLUGIN_ERROR_FAILED; + } else if (g_str_equal (remote_error, "com.endlessm.Updater.Error.MeteredConnection")) { + error->code = GS_PLUGIN_ERROR_NO_NETWORK; + } else if (g_str_equal (remote_error, "com.endlessm.Updater.Error.Cancelled")) { + error->code = GS_PLUGIN_ERROR_CANCELLED; + } else { + g_warning ("Can’t reliably fixup remote error ‘%s’", remote_error); + error->code = GS_PLUGIN_ERROR_FAILED; + } + error->domain = GS_PLUGIN_ERROR; + return; + } + + /* this is allowed for low-level errors */ + if (gs_utils_error_convert_gio (perror)) + return; + + /* this is allowed for low-level errors */ + if (gs_utils_error_convert_gdbus (perror)) + return; +} + +/* the percentage of the progress bar to use for applying the OS upgrade; + * we need to fake the progress in this percentage because applying the OS upgrade + * can take a long time and we don't want the user to think that the upgrade has + * stalled */ +static const guint upgrade_apply_progress_range = 100 - max_progress_for_update; /* percent */ +static const gfloat upgrade_apply_max_time = 600.0; /* sec */ +static const gfloat upgrade_apply_step_time = 0.250; /* sec */ + +static void sync_state_from_updater_unlocked (GsPluginEosUpdater *self); + +struct _GsPluginEosUpdater +{ + GsPlugin parent; + + /* These members are only set once in gs_plugin_eos_updater_setup(), and are + * internally thread-safe, so can be accessed without holding @mutex: */ + GsEosUpdater *updater_proxy; /* (owned) */ + GsApp *os_upgrade; /* (owned) */ + GCancellable *cancellable; /* (owned) */ + gulong cancelled_id; + + /* These members must only ever be accessed from the main thread, so + * can be accessed without holding @mutex: */ + gfloat upgrade_fake_progress; + guint upgrade_fake_progress_handler; + + /* State synchronisation between threads: */ + GMutex mutex; + GCond state_change_cond; /* locked by @mutex */ +}; + +G_DEFINE_TYPE (GsPluginEosUpdater, gs_plugin_eos_updater, GS_TYPE_PLUGIN) + +static void +os_upgrade_cancelled_cb (GCancellable *cancellable, + gpointer user_data) +{ + GsPluginEosUpdater *self = GS_PLUGIN_EOS_UPDATER (user_data); + + g_debug ("%s: Cancelling upgrade", G_STRFUNC); + gs_eos_updater_call_cancel (self->updater_proxy, NULL, NULL, NULL); +} + +static gboolean +should_add_os_upgrade (GsAppState state) +{ + switch (state) { + case GS_APP_STATE_AVAILABLE: + case GS_APP_STATE_AVAILABLE_LOCAL: + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_QUEUED_FOR_INSTALL: + case GS_APP_STATE_INSTALLING: + case GS_APP_STATE_UPDATABLE_LIVE: + return TRUE; + case GS_APP_STATE_UNKNOWN: + case GS_APP_STATE_INSTALLED: + case GS_APP_STATE_UNAVAILABLE: + case GS_APP_STATE_REMOVING: + default: + return FALSE; + } +} + +/* Wrapper around gs_app_set_state() which ensures we also notify of update + * changes if we change between non-upgradable and upgradable states, so that + * the app is notified to appear in the UI. */ +static void +app_set_state (GsPlugin *plugin, + GsApp *app, + GsAppState new_state) +{ + GsAppState old_state = gs_app_get_state (app); + + if (new_state == old_state) + return; + + gs_app_set_state (app, new_state); + + if (should_add_os_upgrade (old_state) != + should_add_os_upgrade (new_state)) { + g_debug ("%s: Calling gs_plugin_updates_changed()", G_STRFUNC); + gs_plugin_updates_changed (plugin); + } +} + +static gboolean +eos_updater_error_is_cancelled (const gchar *error_name) +{ + return (g_strcmp0 (error_name, "com.endlessm.Updater.Error.Cancelled") == 0); +} + +/* This will be invoked in the main thread. */ +static void +updater_state_changed (GsPluginEosUpdater *self) +{ + g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&self->mutex); + + g_debug ("%s", G_STRFUNC); + + sync_state_from_updater_unlocked (self); + + /* Signal any blocked threads; typically this will be + * gs_plugin_app_upgrade_download() in a #GTask worker thread. */ + g_cond_broadcast (&self->state_change_cond); +} + +/* This will be invoked in the main thread. */ +static void +updater_downloaded_bytes_changed (GsPluginEosUpdater *self) +{ + g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&self->mutex); + + sync_state_from_updater_unlocked (self); +} + +/* This will be invoked in the main thread, but doesn’t currently need to hold + * `mutex` since it only accesses `self->updater_proxy` and `self->os_upgrade`, + * both of which are internally thread-safe. */ +static void +updater_version_changed (GsPluginEosUpdater *self) +{ + const gchar *version = gs_eos_updater_get_version (self->updater_proxy); + + /* If eos-updater goes away, we want to retain the previously set value + * of the version, for use in error messages. */ + if (version != NULL) + gs_app_set_version (self->os_upgrade, version); +} + +/* This will be invoked in the main thread, but doesn’t currently need to hold + * `mutex` since `self->updater_proxy` and `self->os_upgrade` are both + * thread-safe, and `self->upgrade_fake_progress` and + * `self->upgrade_fake_progress_handler` are only ever accessed from the main + * thread. */ +static gboolean +fake_os_upgrade_progress_cb (gpointer user_data) +{ + GsPluginEosUpdater *self = GS_PLUGIN_EOS_UPDATER (user_data); + gfloat normal_step; + guint new_progress; + const gfloat fake_progress_max = 99.0; + + if (gs_eos_updater_get_state (self->updater_proxy) != EOS_UPDATER_STATE_APPLYING_UPDATE || + self->upgrade_fake_progress > fake_progress_max) { + self->upgrade_fake_progress = 0; + self->upgrade_fake_progress_handler = 0; + return G_SOURCE_REMOVE; + } + + normal_step = (gfloat) upgrade_apply_progress_range / + (upgrade_apply_max_time / upgrade_apply_step_time); + + self->upgrade_fake_progress += normal_step; + + new_progress = max_progress_for_update + + (guint) round (self->upgrade_fake_progress); + gs_app_set_progress (self->os_upgrade, + MIN (new_progress, (guint) fake_progress_max)); + + g_debug ("OS upgrade fake progress: %f", self->upgrade_fake_progress); + + return G_SOURCE_CONTINUE; +} + +/* This method deals with the synchronization between the EOS updater's states + * (D-Bus service) and the OS upgrade's states (GsApp), in order to show the user + * what is happening and what they can do. + * + * It must be called with self->mutex already locked. */ +static void +sync_state_from_updater_unlocked (GsPluginEosUpdater *self) +{ + GsPlugin *plugin = GS_PLUGIN (self); + GsApp *app = self->os_upgrade; + EosUpdaterState state; + GsAppState previous_app_state = gs_app_get_state (app); + GsAppState current_app_state; + + /* in case the OS upgrade has been disabled */ + if (self->updater_proxy == NULL) { + g_debug ("%s: Updater disabled", G_STRFUNC); + return; + } + + state = gs_eos_updater_get_state (self->updater_proxy); + g_debug ("EOS Updater state changed: %s", eos_updater_state_to_str (state)); + + switch (state) { + case EOS_UPDATER_STATE_NONE: + case EOS_UPDATER_STATE_READY: { + app_set_state (plugin, app, GS_APP_STATE_UNKNOWN); + break; + } case EOS_UPDATER_STATE_POLLING: { + /* Nothing to do here. */ + break; + } case EOS_UPDATER_STATE_UPDATE_AVAILABLE: { + gint64 total_size; + + app_set_state (plugin, app, GS_APP_STATE_AVAILABLE); + + /* The property returns -1 to indicate unknown size */ + total_size = gs_eos_updater_get_download_size (self->updater_proxy); + if (total_size >= 0) + gs_app_set_size_download (app, GS_SIZE_TYPE_VALID, total_size); + else + gs_app_set_size_download (app, GS_SIZE_TYPE_UNKNOWN, 0); + + break; + } + case EOS_UPDATER_STATE_FETCHING: { + gint64 total_size = 0; + gint64 downloaded = 0; + guint progress = 0; + + /* FIXME: Set to QUEUED_FOR_INSTALL if we’re waiting for metered + * data permission. */ + app_set_state (plugin, app, GS_APP_STATE_INSTALLING); + + downloaded = gs_eos_updater_get_downloaded_bytes (self->updater_proxy); + total_size = gs_eos_updater_get_download_size (self->updater_proxy); + + if (total_size == 0) { + g_debug ("OS upgrade %s total size is 0!", + gs_app_get_unique_id (app)); + progress = GS_APP_PROGRESS_UNKNOWN; + } else if (downloaded < 0 || total_size < 0) { + /* Both properties return -1 to indicate unknown */ + progress = GS_APP_PROGRESS_UNKNOWN; + } else { + /* set progress only up to a max percentage, leaving the + * remaining for applying the update */ + progress = (gfloat) downloaded / (gfloat) total_size * + (gfloat) max_progress_for_update; + } + gs_app_set_progress (app, progress); + + break; + } + case EOS_UPDATER_STATE_UPDATE_READY: { + app_set_state (plugin, app, GS_APP_STATE_UPDATABLE); + break; + } + case EOS_UPDATER_STATE_APPLYING_UPDATE: { + /* set as 'installing' because if it is applying the update, we + * want to show the progress bar */ + app_set_state (plugin, app, GS_APP_STATE_INSTALLING); + + /* set up the fake progress to inform the user that something + * is still being done (we don't get progress reports from + * deploying updates) */ + if (self->upgrade_fake_progress_handler != 0) + g_source_remove (self->upgrade_fake_progress_handler); + self->upgrade_fake_progress = 0; + self->upgrade_fake_progress_handler = + g_timeout_add ((guint) (1000.0 * upgrade_apply_step_time), + (GSourceFunc) fake_os_upgrade_progress_cb, + self); + + break; + } + case EOS_UPDATER_STATE_UPDATE_APPLIED: { + app_set_state (plugin, app, GS_APP_STATE_UPDATABLE); + + break; + } + case EOS_UPDATER_STATE_ERROR: { + const gchar *error_name; + const gchar *error_message; + + error_name = gs_eos_updater_get_error_name (self->updater_proxy); + error_message = gs_eos_updater_get_error_message (self->updater_proxy); + + /* unless the error is because the user cancelled the upgrade, + * we should make sure it gets in the journal */ + if (!eos_updater_error_is_cancelled (error_name)) + g_warning ("Got OS upgrade error state with name '%s': %s", + error_name, error_message); + + /* We can’t recover the app state since eos-updater needs to + * go through the ready → poll → fetch → apply loop again in + * order to recover its state. So go back to ‘unknown’. */ + app_set_state (plugin, app, GS_APP_STATE_UNKNOWN); + + /* Cancelling anything in the updater will result in a + * transition to the Error state. Use that as a cue to reset + * our #GCancellable ready for next time. */ + g_cancellable_reset (self->cancellable); + + break; + } + default: + g_warning ("Encountered unknown eos-updater state: %u", state); + break; + } + + current_app_state = gs_app_get_state (app); + + g_debug ("%s: Old app state: %s; new app state: %s", + G_STRFUNC, gs_app_state_to_string (previous_app_state), + gs_app_state_to_string (current_app_state)); + + /* if the state changed from or to 'unknown', we need to notify that a + * new update should be shown */ + if (should_add_os_upgrade (previous_app_state) != + should_add_os_upgrade (current_app_state)) { + g_debug ("%s: Calling gs_plugin_updates_changed()", G_STRFUNC); + gs_plugin_updates_changed (plugin); + } +} + +static void proxy_new_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data); + +/* This is called in the main thread, so will end up creating an @updater_proxy + * which is tied to the main thread’s #GMainContext. */ +static void +gs_plugin_eos_updater_setup_async (GsPlugin *plugin, + GCancellable *cancellable, + GAsyncReadyCallback callback, + gpointer user_data) +{ + GsPluginEosUpdater *self = GS_PLUGIN_EOS_UPDATER (plugin); + g_autoptr(GMutexLocker) locker = NULL; + g_autoptr(GTask) task = NULL; + + task = g_task_new (plugin, cancellable, callback, user_data); + g_task_set_source_tag (task, gs_plugin_eos_updater_setup_async); + + g_debug ("%s", G_STRFUNC); + + g_mutex_init (&self->mutex); + g_cond_init (&self->state_change_cond); + + locker = g_mutex_locker_new (&self->mutex); + + self->cancellable = g_cancellable_new (); + self->cancelled_id = + g_cancellable_connect (self->cancellable, + G_CALLBACK (os_upgrade_cancelled_cb), + self, NULL); + + /* Check that the proxy exists (and is owned; it should auto-start) so + * we can disable the plugin for systems which don’t have eos-updater. + * Throughout the rest of the plugin, errors from the daemon + * (particularly where it has disappeared off the bus) are ignored, and + * the poll/fetch/apply sequence is run through again to recover from + * the error. This is the only point in the plugin where we consider an + * error from eos-updater to be fatal to the plugin. */ + gs_eos_updater_proxy_new (gs_plugin_get_system_bus_connection (plugin), + G_DBUS_PROXY_FLAGS_NONE, + "com.endlessm.Updater", + "/com/endlessm/Updater", + cancellable, + proxy_new_cb, + g_steal_pointer (&task)); +} + +static void +proxy_new_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GTask) task = g_steal_pointer (&user_data); + GsPluginEosUpdater *self = g_task_get_source_object (task); + g_autofree gchar *name_owner = NULL; + g_autoptr(GsApp) app = NULL; + g_autoptr(GIcon) ic = NULL; + g_autofree gchar *background_filename = NULL; + g_autofree gchar *css = NULL; + g_autofree gchar *summary = NULL; + g_autofree gchar *version = NULL; + g_autoptr(GsOsRelease) os_release = NULL; + g_autoptr(GMutexLocker) locker = NULL; + g_autoptr(GError) local_error = NULL; + const gchar *os_name; + + locker = g_mutex_locker_new (&self->mutex); + + self->updater_proxy = gs_eos_updater_proxy_new_finish (result, &local_error); + if (self->updater_proxy == NULL) { + gs_eos_updater_error_convert (&local_error); + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + name_owner = g_dbus_proxy_get_name_owner (G_DBUS_PROXY (self->updater_proxy)); + + if (name_owner == NULL) { + g_task_return_new_error (task, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NOT_SUPPORTED, + "Couldn’t create EOS Updater proxy: couldn’t get name owner"); + return; + } + + g_signal_connect_object (self->updater_proxy, "notify::state", + G_CALLBACK (updater_state_changed), + self, G_CONNECT_SWAPPED); + g_signal_connect_object (self->updater_proxy, + "notify::downloaded-bytes", + G_CALLBACK (updater_downloaded_bytes_changed), + self, G_CONNECT_SWAPPED); + g_signal_connect_object (self->updater_proxy, "notify::version", + G_CALLBACK (updater_version_changed), + self, G_CONNECT_SWAPPED); + + /* prepare EOS upgrade app + sync initial state */ + + /* use stock icon */ + ic = g_themed_icon_new ("system-component-addon"); + + /* Check for a background image in the standard location. */ + background_filename = gs_utils_get_upgrade_background (NULL); + + if (background_filename != NULL) + css = g_strconcat ("background: url('file://", background_filename, "');" + "background-size: 100% 100%;", NULL); + + os_release = gs_os_release_new (&local_error); + if (local_error) { + g_warning ("Failed to get OS release information: %s", local_error->message); + /* Just a fallback, do not localize */ + os_name = "Endless OS"; + g_clear_error (&local_error); + } else { + os_name = gs_os_release_get_name (os_release); + } + + g_object_get (G_OBJECT (self->updater_proxy), + "version", &version, + "update-message", &summary, + NULL); + + if (summary == NULL || *summary == '\0') { + g_clear_pointer (&summary, g_free); + g_object_get (G_OBJECT (self->updater_proxy), + "update-label", &summary, + NULL); + } + + if (summary == NULL || *summary == '\0') { + g_clear_pointer (&summary, g_free); + /* Translators: The '%s' is replaced with the OS name, like "Endless OS" */ + summary = g_strdup_printf (_("%s update with new features and fixes."), os_name); + } + + /* create the OS upgrade */ + app = gs_app_new ("com.endlessm.EOS.upgrade"); + gs_app_add_icon (app, ic); + gs_app_set_scope (app, AS_COMPONENT_SCOPE_SYSTEM); + gs_app_set_kind (app, AS_COMPONENT_KIND_OPERATING_SYSTEM); + gs_app_set_name (app, GS_APP_QUALITY_LOWEST, os_name); + gs_app_set_summary (app, GS_APP_QUALITY_NORMAL, summary); + gs_app_set_version (app, version == NULL ? "" : version); + gs_app_add_quirk (app, GS_APP_QUIRK_NEEDS_REBOOT); + gs_app_add_quirk (app, GS_APP_QUIRK_PROVENANCE); + gs_app_add_quirk (app, GS_APP_QUIRK_NOT_REVIEWABLE); + gs_app_set_management_plugin (app, GS_PLUGIN (self)); + gs_app_set_metadata (app, "GnomeSoftware::UpgradeBanner-css", css); + + self->os_upgrade = g_steal_pointer (&app); + + /* sync initial state */ + sync_state_from_updater_unlocked (self); + + g_task_return_boolean (task, TRUE); +} + +static gboolean +gs_plugin_eos_updater_setup_finish (GsPlugin *plugin, + GAsyncResult *result, + GError **error) +{ + return g_task_propagate_boolean (G_TASK (result), error); +} + +static void +gs_plugin_eos_updater_init (GsPluginEosUpdater *self) +{ +} + +static void +gs_plugin_eos_updater_dispose (GObject *object) +{ + GsPluginEosUpdater *self = GS_PLUGIN_EOS_UPDATER (object); + + if (self->upgrade_fake_progress_handler != 0) { + g_source_remove (self->upgrade_fake_progress_handler); + self->upgrade_fake_progress_handler = 0; + } + + if (self->updater_proxy != NULL) { + g_signal_handlers_disconnect_by_func (self->updater_proxy, + G_CALLBACK (updater_state_changed), + self); + g_signal_handlers_disconnect_by_func (self->updater_proxy, + G_CALLBACK (updater_downloaded_bytes_changed), + self); + g_signal_handlers_disconnect_by_func (self->updater_proxy, + G_CALLBACK (updater_version_changed), + self); + } + + g_cancellable_cancel (self->cancellable); + if (self->cancellable != NULL && self->cancelled_id != 0) + g_cancellable_disconnect (self->cancellable, self->cancelled_id); + g_clear_object (&self->cancellable); + + g_clear_object (&self->updater_proxy); + + g_clear_object (&self->os_upgrade); + + G_OBJECT_CLASS (gs_plugin_eos_updater_parent_class)->dispose (object); +} + +static void +gs_plugin_eos_updater_finalize (GObject *object) +{ + GsPluginEosUpdater *self = GS_PLUGIN_EOS_UPDATER (object); + + g_cond_clear (&self->state_change_cond); + g_mutex_clear (&self->mutex); + + G_OBJECT_CLASS (gs_plugin_eos_updater_parent_class)->finalize (object); +} + +static void poll_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data); + +/* Called in the main thread. */ +static void +gs_plugin_eos_updater_refresh_metadata_async (GsPlugin *plugin, + guint64 cache_age_secs, + GsPluginRefreshMetadataFlags flags, + GCancellable *cancellable, + GAsyncReadyCallback callback, + gpointer user_data) +{ + GsPluginEosUpdater *self = GS_PLUGIN_EOS_UPDATER (plugin); + EosUpdaterState updater_state; + g_autoptr(GTask) task = NULL; + + task = g_task_new (plugin, cancellable, callback, user_data); + g_task_set_source_tag (task, gs_plugin_eos_updater_refresh_metadata_async); + + /* We let the eos-updater daemon do its own caching, so ignore the + * @cache_age_secs, unless it’s %G_MAXUINT64, which signifies startup of g-s. + * In that case, it’s probably just going to load the system too much to + * do an update check now. We can wait. */ + g_debug ("%s: cache_age_secs: %" G_GUINT64_FORMAT, G_STRFUNC, cache_age_secs); + + if (cache_age_secs == G_MAXUINT64) { + g_task_return_boolean (task, TRUE); + return; + } + + /* check if the OS upgrade has been disabled */ + if (self->updater_proxy == NULL) { + g_debug ("%s: Updater disabled", G_STRFUNC); + g_task_return_boolean (task, TRUE); + return; + } + + /* poll in the error/none/ready states to check if there's an + * update available */ + updater_state = gs_eos_updater_get_state (self->updater_proxy); + switch (updater_state) { + case EOS_UPDATER_STATE_ERROR: + case EOS_UPDATER_STATE_NONE: + case EOS_UPDATER_STATE_READY: + gs_eos_updater_call_poll (self->updater_proxy, + cancellable, + poll_cb, + g_steal_pointer (&task)); + return; + default: + g_debug ("%s: Updater in state %s; not polling", + G_STRFUNC, eos_updater_state_to_str (updater_state)); + g_task_return_boolean (task, TRUE); + return; + } +} + +static void +poll_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + GsEosUpdater *updater_proxy = GS_EOS_UPDATER (source_object); + g_autoptr(GTask) task = g_steal_pointer (&user_data); + g_autoptr(GError) local_error = NULL; + + if (!gs_eos_updater_call_poll_finish (updater_proxy, result, &local_error)) { + gs_eos_updater_error_convert (&local_error); + g_task_return_error (task, g_steal_pointer (&local_error)); + } else { + g_task_return_boolean (task, TRUE); + } +} + +static gboolean +gs_plugin_eos_updater_refresh_metadata_finish (GsPlugin *plugin, + GAsyncResult *result, + GError **error) +{ + return g_task_propagate_boolean (G_TASK (result), error); +} + +/* Called in the main thread. */ +static void +gs_plugin_eos_updater_list_distro_upgrades_async (GsPlugin *plugin, + GsPluginListDistroUpgradesFlags flags, + GCancellable *cancellable, + GAsyncReadyCallback callback, + gpointer user_data) +{ + GsPluginEosUpdater *self = GS_PLUGIN_EOS_UPDATER (plugin); + g_autoptr(GTask) task = NULL; + g_autoptr(GsAppList) list = gs_app_list_new (); + + task = g_task_new (plugin, cancellable, callback, user_data); + g_task_set_source_tag (task, gs_plugin_eos_updater_list_distro_upgrades_async); + + g_debug ("%s", G_STRFUNC); + + /* if we are testing the plugin, then always add the OS upgrade */ + if (g_getenv ("GS_PLUGIN_EOS_TEST") != NULL) { + gs_app_set_state (self->os_upgrade, GS_APP_STATE_AVAILABLE); + gs_app_list_add (list, self->os_upgrade); + g_task_return_pointer (task, g_steal_pointer (&list), g_object_unref); + return; + } + + /* check if the OS upgrade has been disabled */ + if (self->updater_proxy == NULL) { + g_debug ("%s: Updater disabled", G_STRFUNC); + g_task_return_pointer (task, g_steal_pointer (&list), g_object_unref); + return; + } + + if (should_add_os_upgrade (gs_app_get_state (self->os_upgrade))) { + g_debug ("Adding EOS upgrade: %s", + gs_app_get_unique_id (self->os_upgrade)); + gs_app_list_add (list, self->os_upgrade); + } else { + g_debug ("Not adding EOS upgrade"); + } + + g_task_return_pointer (task, g_steal_pointer (&list), g_object_unref); +} + +static GsAppList * +gs_plugin_eos_updater_list_distro_upgrades_finish (GsPlugin *plugin, + GAsyncResult *result, + GError **error) +{ + return g_task_propagate_pointer (G_TASK (result), error); +} + +/* Must be called with self->mutex already locked. */ +static gboolean +wait_for_state_change_unlocked (GsPluginEosUpdater *self, + GCancellable *cancellable, + GError **error) +{ + EosUpdaterState old_state, new_state; + + old_state = new_state = gs_eos_updater_get_state (self->updater_proxy); + g_debug ("%s: Old state ‘%s’", G_STRFUNC, eos_updater_state_to_str (old_state)); + + while (new_state == old_state && + !g_cancellable_is_cancelled (cancellable)) { + g_cond_wait (&self->state_change_cond, &self->mutex); + new_state = gs_eos_updater_get_state (self->updater_proxy); + } + + if (!g_cancellable_set_error_if_cancelled (cancellable, error)) { + g_debug ("%s: New state ‘%s’", G_STRFUNC, eos_updater_state_to_str (new_state)); + return TRUE; + } else { + g_debug ("%s: Cancelled", G_STRFUNC); + return FALSE; + } +} + +/* Could be executed in any thread. No need to hold `self->mutex` since we don’t + * access anything which is not thread-safe. */ +static void +cancelled_cb (GCancellable *ui_cancellable, + gpointer user_data) +{ + GsPluginEosUpdater *self = GS_PLUGIN_EOS_UPDATER (user_data); + + /* Chain cancellation. */ + g_debug ("Propagating OS download cancellation from %p to %p", + ui_cancellable, self->cancellable); + g_cancellable_cancel (self->cancellable); + + /* And wake up anything blocking on a state change. */ + g_cond_broadcast (&self->state_change_cond); +} + +/* Called in a #GTask worker thread, and it needs to hold `self->mutex` due to + * synchronising on state with the main thread. */ +gboolean +gs_plugin_app_upgrade_download (GsPlugin *plugin, + GsApp *app, + GCancellable *cancellable, + GError **error) +{ + GsPluginEosUpdater *self = GS_PLUGIN_EOS_UPDATER (plugin); + gulong cancelled_id = 0; + EosUpdaterState state; + gboolean done, allow_restart; + g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&self->mutex); + + /* only process this app if was created by this plugin */ + if (!gs_app_has_management_plugin (app, plugin)) + return TRUE; + + /* if the OS upgrade has been disabled */ + if (self->updater_proxy == NULL) { + g_set_error (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_FAILED, + "The OS upgrade has been disabled in the EOS plugin"); + return FALSE; + } + + g_assert (app == self->os_upgrade); + + /* Set up cancellation. */ + g_debug ("Chaining cancellation from %p to %p", cancellable, self->cancellable); + if (cancellable != NULL) { + cancelled_id = g_cancellable_connect (cancellable, + G_CALLBACK (cancelled_cb), + plugin, NULL); + } + + /* Step through the state machine until we are finished downloading and + * applying the update, or until an error occurs. All of the D-Bus calls + * here will block until the method call is complete. */ + state = gs_eos_updater_get_state (self->updater_proxy); + + done = FALSE; + allow_restart = (state == EOS_UPDATER_STATE_NONE || + state == EOS_UPDATER_STATE_READY || + state == EOS_UPDATER_STATE_ERROR); + + while (!done && !g_cancellable_is_cancelled (cancellable)) { + state = gs_eos_updater_get_state (self->updater_proxy); + g_debug ("%s: State ‘%s’", G_STRFUNC, eos_updater_state_to_str (state)); + + switch (state) { + case EOS_UPDATER_STATE_NONE: + case EOS_UPDATER_STATE_READY: { + /* Poll for an update. This typically only happens if + * we’ve drifted out of sync with the updater process + * due to it dying. In that case, only restart once + * before giving up, so we don’t end up in an endless + * loop (say, if eos-updater always died 50% of the way + * through a download). */ + if (allow_restart) { + allow_restart = FALSE; + g_debug ("Restarting OS upgrade from none/ready state"); + if (!gs_eos_updater_call_poll_sync (self->updater_proxy, + cancellable, error)) { + gs_eos_updater_error_convert (error); + return FALSE; + } + } else { + /* Display an error to the user. */ + g_autoptr(GError) error_local = NULL; + g_autoptr(GsPluginEvent) event = NULL; + + g_set_error_literal (&error_local, GS_PLUGIN_ERROR, + GS_PLUGIN_ERROR_FAILED, + _("EOS update service could not fetch and apply the update.")); + gs_eos_updater_error_convert (&error_local); + + event = gs_plugin_event_new ("app", app, + "action", GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD, + "error", error_local, + NULL); + gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_WARNING); + gs_plugin_report_event (plugin, event); + + /* Error out. */ + done = TRUE; + } + + break; + } case EOS_UPDATER_STATE_POLLING: { + /* Nothing to do here. */ + break; + } case EOS_UPDATER_STATE_UPDATE_AVAILABLE: { + g_auto(GVariantDict) options_dict = G_VARIANT_DICT_INIT (NULL); + + /* when the OS upgrade was started by the user and the + * updater reports an available update, (meaning we were + * polling before), we should readily call fetch */ + g_variant_dict_insert (&options_dict, "force", "b", TRUE); + + if (!gs_eos_updater_call_fetch_full_sync (self->updater_proxy, + g_variant_dict_end (&options_dict), + cancellable, error)) { + gs_eos_updater_error_convert (error); + return FALSE; + } + + break; + } + case EOS_UPDATER_STATE_FETCHING: { + /* Nothing to do here. */ + break; + } + case EOS_UPDATER_STATE_UPDATE_READY: { + /* if there's an update ready to deployed, and it was started by + * the user, we should proceed to applying the upgrade */ + gs_app_set_progress (app, max_progress_for_update); + + if (!gs_eos_updater_call_apply_sync (self->updater_proxy, + cancellable, error)) { + gs_eos_updater_error_convert (error); + return FALSE; + } + + break; + } + case EOS_UPDATER_STATE_APPLYING_UPDATE: { + /* Nothing to do here. */ + break; + } + case EOS_UPDATER_STATE_UPDATE_APPLIED: { + /* Done! */ + done = TRUE; + break; + } + case EOS_UPDATER_STATE_ERROR: { + const gchar *error_name; + const gchar *error_message; + g_autoptr(GError) error_local = NULL; + + error_name = gs_eos_updater_get_error_name (self->updater_proxy); + error_message = gs_eos_updater_get_error_message (self->updater_proxy); + error_local = g_dbus_error_new_for_dbus_error (error_name, error_message); + + /* Display an error to the user, unless they cancelled + * the download. */ + if (!eos_updater_error_is_cancelled (error_name)) { + g_autoptr(GsPluginEvent) event = NULL; + + gs_eos_updater_error_convert (&error_local); + + event = gs_plugin_event_new ("app", app, + "action", GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD, + "error", error_local, + NULL); + gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_WARNING); + gs_plugin_report_event (plugin, event); + } + + /* Unconditionally call Poll() to get the updater out + * of the error state and to allow the update to be + * displayed in the UI again and retried. Exit the + * state change loop immediately, though, to prevent + * possible endless loops between the Poll/Error + * states. */ + allow_restart = FALSE; + g_debug ("Restarting OS upgrade on error"); + if (!gs_eos_updater_call_poll_sync (self->updater_proxy, + cancellable, error)) { + gs_eos_updater_error_convert (error); + return FALSE; + } + + /* Error out. */ + done = TRUE; + + break; + } + default: + g_warning ("Encountered unknown eos-updater state: %u", state); + break; + } + + /* Block on the next state change. */ + if (!done && + !wait_for_state_change_unlocked (self, cancellable, error)) { + gs_eos_updater_error_convert (error); + return FALSE; + } + } + + if (cancellable != NULL && cancelled_id != 0) { + g_debug ("Disconnecting cancellable %p", cancellable); + g_cancellable_disconnect (cancellable, cancelled_id); + } + + /* Process the final state. */ + if (gs_eos_updater_get_state (self->updater_proxy) == EOS_UPDATER_STATE_ERROR) { + const gchar *error_name; + const gchar *error_message; + g_autoptr(GError) error_local = NULL; + + error_name = gs_eos_updater_get_error_name (self->updater_proxy); + error_message = gs_eos_updater_get_error_message (self->updater_proxy); + error_local = g_dbus_error_new_for_dbus_error (error_name, error_message); + gs_eos_updater_error_convert (&error_local); + g_propagate_error (error, g_steal_pointer (&error_local)); + + return FALSE; + } else if (g_cancellable_set_error_if_cancelled (cancellable, error)) { + gs_eos_updater_error_convert (error); + return FALSE; + } + + return TRUE; +} + +static void +gs_plugin_eos_updater_class_init (GsPluginEosUpdaterClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GsPluginClass *plugin_class = GS_PLUGIN_CLASS (klass); + + object_class->dispose = gs_plugin_eos_updater_dispose; + object_class->finalize = gs_plugin_eos_updater_finalize; + + plugin_class->setup_async = gs_plugin_eos_updater_setup_async; + plugin_class->setup_finish = gs_plugin_eos_updater_setup_finish; + plugin_class->refresh_metadata_async = gs_plugin_eos_updater_refresh_metadata_async; + plugin_class->refresh_metadata_finish = gs_plugin_eos_updater_refresh_metadata_finish; + plugin_class->list_distro_upgrades_async = gs_plugin_eos_updater_list_distro_upgrades_async; + plugin_class->list_distro_upgrades_finish = gs_plugin_eos_updater_list_distro_upgrades_finish; +} + +GType +gs_plugin_query_type (void) +{ + return GS_TYPE_PLUGIN_EOS_UPDATER; +} diff --git a/plugins/eos-updater/gs-plugin-eos-updater.h b/plugins/eos-updater/gs-plugin-eos-updater.h new file mode 100644 index 0000000..a87b649 --- /dev/null +++ b/plugins/eos-updater/gs-plugin-eos-updater.h @@ -0,0 +1,22 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> + +G_BEGIN_DECLS + +#define GS_TYPE_PLUGIN_EOS_UPDATER (gs_plugin_eos_updater_get_type ()) + +G_DECLARE_FINAL_TYPE (GsPluginEosUpdater, gs_plugin_eos_updater, GS, PLUGIN_EOS_UPDATER, GsPlugin) + +G_END_DECLS diff --git a/plugins/eos-updater/meson.build b/plugins/eos-updater/meson.build new file mode 100644 index 0000000..19edf22 --- /dev/null +++ b/plugins/eos-updater/meson.build @@ -0,0 +1,25 @@ +cargs = ['-DG_LOG_DOMAIN="GsPluginEosUpdater"'] + +eos_updater_generated = gnome.gdbus_codegen( + 'gs-eos-updater-generated', + sources : ['com.endlessm.Updater.xml'], + interface_prefix : 'com.endlessm.', + namespace : 'GsEos', +) + +shared_module( + 'gs_plugin_eos-updater', + eos_updater_generated, + sources : 'gs-plugin-eos-updater.c', + include_directories : [ + include_directories('../..'), + include_directories('../../lib'), + ], + install : true, + install_dir: plugin_dir, + c_args : cargs, + dependencies : [ + plugin_libs, + ostree, + ], +) diff --git a/plugins/eos-updater/tests/eos_updater.py b/plugins/eos-updater/tests/eos_updater.py new file mode 100644 index 0000000..5e4aa8d --- /dev/null +++ b/plugins/eos-updater/tests/eos_updater.py @@ -0,0 +1,414 @@ +'''eos-updater mock template + +This creates a mock eos-updater interface (com.endlessm.Updater), with several +methods on the Mock sidecar interface which allow its internal state flow to be +controlled. + +A typical call chain for this would be: + - Test harness calls SetPollAction('update', {}, '', '') + - SUT calls Poll() + - Test harness calls FinishPoll() + - SUT calls Fetch() + - Test harness calls FinishFetch() + - SUT calls Apply() + - Test harness calls FinishApply() + +Errors can be simulated by specifying an `early-error` or `late-error` as the +action in a Set*Action() call. `early-error` will result in the associated +Poll() call (for example) transitioning to the error state. `late-error` will +result in a transition to the error state only once (for example) FinishPoll() +is called. + +See the implementation of each Set*Action() method for the set of actions it +supports. + +Usage: + python3 -m dbusmock \ + --template ./plugins/eos-updater/tests/mock-eos-updater.py +''' + +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. See http://www.gnu.org/copyleft/lgpl.html for the full +# text of the license. +# +# The LGPL 2.1+ has been chosen as that’s the license eos-updater is under. + +from enum import IntEnum +from gi.repository import GLib +import time + +import dbus +import dbus.mainloop.glib +from dbusmock import MOCK_IFACE + + +__author__ = 'Philip Withnall' +__email__ = 'withnall@endlessm.com' +__copyright__ = '© 2019 Endless Mobile Inc.' +__license__ = 'LGPL 2.1+' + + +class UpdaterState(IntEnum): + NONE = 0 + READY = 1 + ERROR = 2 + POLLING = 3 + UPDATE_AVAILABLE = 4 + FETCHING = 5 + UPDATE_READY = 6 + APPLYING_UPDATE = 7 + UPDATE_APPLIED = 8 + + +BUS_NAME = 'com.endlessm.Updater' +MAIN_OBJ = '/com/endlessm/Updater' +MAIN_IFACE = 'com.endlessm.Updater' +SYSTEM_BUS = True + + +dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + + +def load(mock, parameters): + mock.AddProperties( + MAIN_IFACE, + dbus.Dictionary({ + 'State': dbus.UInt32(parameters.get('State', 1)), + 'UpdateID': dbus.String(parameters.get('UpdateID', '')), + 'UpdateRefspec': dbus.String(parameters.get('UpdateRefspec', '')), + 'OriginalRefspec': + dbus.String(parameters.get('OriginalRefspec', '')), + 'CurrentID': dbus.String(parameters.get('CurrentID', '')), + 'UpdateLabel': dbus.String(parameters.get('UpdateLabel', '')), + 'UpdateMessage': dbus.String(parameters.get('UpdateMessage', '')), + 'Version': dbus.String(parameters.get('Version', '')), + 'DownloadSize': dbus.Int64(parameters.get('DownloadSize', 0)), + 'DownloadedBytes': + dbus.Int64(parameters.get('DownloadedBytes', 0)), + 'UnpackedSize': dbus.Int64(parameters.get('UnpackedSize', 0)), + 'FullDownloadSize': + dbus.Int64(parameters.get('FullDownloadSize', 0)), + 'FullUnpackedSize': + dbus.Int64(parameters.get('FullUnpackedSize', 0)), + 'ErrorCode': dbus.UInt32(parameters.get('ErrorCode', 0)), + 'ErrorName': dbus.String(parameters.get('ErrorName', '')), + 'ErrorMessage': dbus.String(parameters.get('ErrorMessage', '')), + }, signature='sv')) + + # Set up initial state + mock.__poll_action = 'no-update' + mock.__fetch_action = 'success' + mock.__apply_action = 'success' + + # Set up private methods + mock.__set_properties = __set_properties + mock.__change_state = __change_state + mock.__set_error = __set_error + mock.__check_state = __check_state + + +# +# Internal utility methods +# + +# Values in @properties must have variant_level≥1 +def __set_properties(self, iface, properties): + for key, value in properties.items(): + self.props[iface][key] = value + self.EmitSignal(dbus.PROPERTIES_IFACE, 'PropertiesChanged', 'sa{sv}as', [ + iface, + properties, + [], + ]) + + +def __change_state(self, new_state): + props = { + 'State': dbus.UInt32(new_state, variant_level=1) + } + + # Reset error state if necessary. + if new_state != UpdaterState.ERROR and \ + self.props[MAIN_IFACE]['ErrorName'] != '': + props['ErrorCode'] = dbus.UInt32(0, variant_level=1) + props['ErrorName'] = dbus.String('', variant_level=1) + props['ErrorMessage'] = dbus.String('', variant_level=1) + + self.__set_properties(self, MAIN_IFACE, props) + self.EmitSignal(MAIN_IFACE, 'StateChanged', 'u', [dbus.UInt32(new_state)]) + + +def __set_error(self, error_name, error_message): + assert(error_name != '') + + self.__set_properties(self, MAIN_IFACE, { + 'ErrorName': dbus.String(error_name, variant_level=1), + 'ErrorMessage': dbus.String(error_message, variant_level=1), + 'ErrorCode': dbus.UInt32(1, variant_level=1), + }) + self.__change_state(self, UpdaterState.ERROR) + + +def __check_state(self, allowed_states): + if self.props[MAIN_IFACE]['State'] not in allowed_states: + raise dbus.exceptions.DBusException( + 'Call not allowed in this state', + name='com.endlessm.Updater.Error.WrongState') + + +# +# Updater methods which are too big for squeezing into AddMethod() +# + +@dbus.service.method(MAIN_IFACE, in_signature='', out_signature='') +def Poll(self): + self.__check_state(self, set([ + UpdaterState.READY, + UpdaterState.UPDATE_AVAILABLE, + UpdaterState.UPDATE_READY, + UpdaterState.ERROR, + ])) + + self.__change_state(self, UpdaterState.POLLING) + + if self.__poll_action == 'early-error': + time.sleep(0.5) + self.__set_error(self, self.__poll_error_name, + self.__poll_error_message) + else: + # we now expect the test harness to call FinishPoll() on the mock + # interface + pass + + +@dbus.service.method(MAIN_IFACE, in_signature='s', out_signature='') +def PollVolume(self, path): + # FIXME: Currently unsupported + return self.Poll() + + +@dbus.service.method(MAIN_IFACE, in_signature='', out_signature='') +def Fetch(self): + return self.FetchFull() + + +@dbus.service.method(MAIN_IFACE, in_signature='a{sv}', out_signature='') +def FetchFull(self, options=None): + self.__check_state(self, set([UpdaterState.UPDATE_AVAILABLE])) + + self.__change_state(self, UpdaterState.FETCHING) + + if self.__fetch_action == 'early-error': + time.sleep(0.5) + self.__set_error(self, self.__fetch_error_name, + self.__fetch_error_message) + else: + # we now expect the test harness to call FinishFetch() on the mock + # interface + pass + + +@dbus.service.method(MAIN_IFACE, in_signature='', out_signature='') +def Apply(self): + self.__check_state(self, set([UpdaterState.UPDATE_READY])) + + self.__change_state(self, UpdaterState.APPLYING_UPDATE) + + if self.__apply_action == 'early-error': + time.sleep(0.5) + self.__set_error(self, self.__apply_error_name, + self.__apply_error_message) + else: + # we now expect the test harness to call FinishApply() on the mock + # interface + pass + + +@dbus.service.method(MAIN_IFACE, in_signature='', out_signature='') +def Cancel(self): + self.__check_state(self, set([ + UpdaterState.POLLING, + UpdaterState.FETCHING, + UpdaterState.APPLYING_UPDATE, + ])) + + time.sleep(1) + self.__set_error(self, 'com.endlessm.Updater.Error.Cancelled', + 'Update was cancelled') + + +# +# Convenience methods on the mock +# + +@dbus.service.method(MOCK_IFACE, in_signature='sa{sv}ss', out_signature='') +def SetPollAction(self, action, update_properties, error_name, error_message): + '''Set the action to happen when the SUT calls Poll(). + + This sets the action which will happen when Poll() (and subsequently + FinishPoll()) are called, including the details of the error which will be + returned or the new update which will be advertised. + ''' + # Provide a default update. + if not update_properties: + update_properties = { + 'UpdateID': dbus.String('f' * 64, variant_level=1), + 'UpdateRefspec': + dbus.String('remote:new-refspec', variant_level=1), + 'OriginalRefspec': + dbus.String('remote:old-refspec', variant_level=1), + 'CurrentID': dbus.String('1' * 64, variant_level=1), + 'UpdateLabel': dbus.String('New OS Update', variant_level=1), + 'UpdateMessage': + dbus.String('Some release notes.', variant_level=1), + 'Version': dbus.String('3.7.0', variant_level=1), + 'DownloadSize': dbus.Int64(1000000000, variant_level=1), + 'UnpackedSize': dbus.Int64(1500000000, variant_level=1), + 'FullDownloadSize': dbus.Int64(1000000000 * 0.8, variant_level=1), + 'FullUnpackedSize': dbus.Int64(1500000000 * 0.8, variant_level=1), + } + + self.__poll_action = action + self.__poll_update_properties = update_properties + self.__poll_error_name = error_name + self.__poll_error_message = error_message + + +@dbus.service.method(MOCK_IFACE, in_signature='', out_signature='') +def FinishPoll(self): + self.__check_state(self, set([UpdaterState.POLLING])) + + if self.__poll_action == 'no-update': + self.__change_state(self, UpdaterState.READY) + elif self.__poll_action == 'update': + assert(set([ + 'UpdateID', + 'UpdateRefspec', + 'OriginalRefspec', + 'CurrentID', + 'UpdateLabel', + 'UpdateMessage', + 'Version', + 'FullDownloadSize', + 'FullUnpackedSize', + 'DownloadSize', + 'UnpackedSize', + ]) <= set(self.__poll_update_properties.keys())) + + # Set the initial DownloadedBytes based on whether we know the full + # download size. + props = self.__poll_update_properties + if props['DownloadSize'] < 0: + props['DownloadedBytes'] = dbus.Int64(-1, variant_level=1) + else: + props['DownloadedBytes'] = dbus.Int64(0, variant_level=1) + + self.__set_properties(self, MAIN_IFACE, props) + self.__change_state(self, UpdaterState.UPDATE_AVAILABLE) + elif self.__poll_action == 'early-error': + # Handled in Poll() itself. + pass + elif self.__poll_action == 'late-error': + self.__set_error(self, self.__poll_error_name, + self.__poll_error_message) + else: + assert(False) + + +@dbus.service.method(MOCK_IFACE, in_signature='sss', out_signature='') +def SetFetchAction(self, action, error_name, error_message): + '''Set the action to happen when the SUT calls Fetch(). + + This sets the action which will happen when Fetch() (and subsequently + FinishFetch()) are called, including the details of the error which will be + returned, if applicable. + ''' + self.__fetch_action = action + self.__fetch_error_name = error_name + self.__fetch_error_message = error_message + + +@dbus.service.method(MOCK_IFACE, in_signature='', out_signature='', + async_callbacks=('success_cb', 'error_cb')) +def FinishFetch(self, success_cb, error_cb): + '''Finish a pending client call to Fetch(). + + This is implemented using async_callbacks since if the fetch action is + ‘success’ it will block until the simulated download is complete, emitting + download progress signals throughout. As it’s implemented asynchronously, + this allows any calls to Cancel() to be handled by the mock service + part-way through the fetch. + ''' + self.__check_state(self, set([UpdaterState.FETCHING])) + + if self.__fetch_action == 'success': + # Simulate the download. + i = 0 + download_size = self.props[MAIN_IFACE]['DownloadSize'] + + def _download_progress_cb(): + nonlocal i + + # Allow cancellation. + if self.props[MAIN_IFACE]['State'] != UpdaterState.FETCHING: + return False + + downloaded_bytes = (i / 100.0) * download_size + self.__set_properties(self, MAIN_IFACE, { + 'DownloadedBytes': + dbus.Int64(downloaded_bytes, variant_level=1), + }) + + i += 1 + + # Keep looping until the download is complete. + if i <= 100: + return True + + # When the download is complete, change the service state and + # finish the asynchronous FinishFetch() call. + self.__change_state(self, UpdaterState.UPDATE_READY) + success_cb() + return False + + GLib.timeout_add(100, _download_progress_cb) + elif self.__fetch_action == 'early-error': + # Handled in Fetch() itself. + success_cb() + elif self.__fetch_action == 'late-error': + self.__set_error(self, self.__fetch_error_name, + self.__fetch_error_message) + success_cb() + else: + assert(False) + + +@dbus.service.method(MOCK_IFACE, in_signature='sss', out_signature='') +def SetApplyAction(self, action, error_name, error_message): + '''Set the action to happen when the SUT calls Apply(). + + This sets the action which will happen when Apply() (and subsequently + FinishApply()) are called, including the details of the error which will be + returned, if applicable. + ''' + self.__apply_action = action + self.__apply_error_name = error_name + self.__apply_error_message = error_message + + +@dbus.service.method(MOCK_IFACE, in_signature='', out_signature='') +def FinishApply(self): + self.__check_state(self, set([UpdaterState.APPLYING_UPDATE])) + + if self.__apply_action == 'success': + self.__change_state(self, UpdaterState.UPDATE_APPLIED) + elif self.__apply_action == 'early-error': + # Handled in Apply() itself. + pass + elif self.__apply_action == 'late-error': + self.__set_error(self, self.__apply_error_name, + self.__apply_error_message) + else: + assert(False) diff --git a/plugins/eos-updater/tests/manual-test.py b/plugins/eos-updater/tests/manual-test.py new file mode 100755 index 0000000..b6413d9 --- /dev/null +++ b/plugins/eos-updater/tests/manual-test.py @@ -0,0 +1,434 @@ +#!/usr/bin/python3 + +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1+ of the License, or (at your option) +# any later version. See http://www.gnu.org/copyleft/lgpl.html for the full +# text of the license. +# +# The LGPL 2.1+ has been chosen as that’s the license eos-updater is under. + + +from enum import IntEnum +import os +import time +import unittest +import dbus +import dbusmock +import ddt + + +__author__ = 'Philip Withnall' +__email__ = 'withnall@endlessm.com' +__copyright__ = '© 2019 Endless Mobile Inc.' +__license__ = 'LGPL 2.1+' + + +class UpdaterState(IntEnum): + '''eos-updater states; see its State property''' + NONE = 0 + READY = 1 + ERROR = 2 + POLLING = 3 + UPDATE_AVAILABLE = 4 + FETCHING = 5 + UPDATE_READY = 6 + APPLYING_UPDATE = 7 + UPDATE_APPLIED = 8 + + +@ddt.ddt +class ManualTest(dbusmock.DBusTestCase): + '''A manual test of the eos-updater plugin in gnome-software. + + It creates a mock eos-updater D-Bus daemon, on the real system bus (because + otherwise gnome-software’s other plugins can’t communicate with their + system daemons; to fix this, we’d need to mock those up too). The test + harness provides the user with instructions about how to run gnome-software + and what to do in it, waiting for them to press enter between steps. + + FIXME: This test could potentially eventually be automated by doing the UI + steps using Dogtail or OpenQA. + + It tests various classes of interaction between the plugin and the daemon: + normal update process (with and without an update available); error returns + from the daemon; cancellation of the daemon by another process; + cancellation of the daemon from gnome-software; and the daemon unexpectedly + going away (i.e. crashing). + ''' + + @classmethod + def setUpClass(cls): + # FIXME: See the comment below about why we currently run on the actual + # system bus. + # cls.start_system_bus() + cls.dbus_con = cls.get_dbus(True) + + def setUp(self): + # Work out the path to the dbusmock template in the same directory as + # this file. + self_path = os.path.dirname(os.path.realpath(__file__)) + template_path = os.path.join(self_path, 'eos_updater.py') + + # Spawn a python-dbusmock server. Use the actual system bus, since + # gnome-software needs to access various other services (such as + # packagekit) which we don’t currently mock (FIXME). + (self.p_mock, self.obj_eos_updater) = self.spawn_server_template( + template_path, {}, stdout=None) + self.dbusmock = dbus.Interface(self.obj_eos_updater, + dbusmock.MOCK_IFACE) + + def tearDown(self): + self.kill_gnome_software() + self.p_mock.terminate() + self.p_mock.wait() + + def launch_gnome_software(self): + '''Instruct the user to launch gnome-software''' + print('Launch gnome-software with:') + print('gnome-software --verbose') + self.manual_check('Press enter to continue') + + def kill_gnome_software(self): + '''Instruct the user to kill gnome-software''' + print('Kill gnome-software with:') + print('pkill gnome-software') + self.manual_check('Press enter to continue') + + def await_state(self, state): + '''Block until eos-updater reaches the given `state`''' + print('Awaiting state %u' % state) + props_iface = dbus.Interface(self.obj_eos_updater, + dbus.PROPERTIES_IFACE) + while props_iface.Get('com.endlessm.Updater', 'State') != state: + time.sleep(0.2) + + def manual_check(self, prompt): + '''Instruct the user to do a manual check and block until done''' + input('\033[92;1m' + prompt + '\033[0m\n') + + def test_poll_no_update(self): + '''Test that no updates are shown if eos-updater successfully says + there are none.''' + self.dbusmock.SetPollAction( + 'no-update', dbus.Dictionary({}, signature='sv'), '', '') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + self.manual_check('Check there are no EOS updates listed') + self.await_state(UpdaterState.READY) + + @ddt.data('com.endlessm.Updater.Error.WrongState', + 'com.endlessm.Updater.Error.LiveBoot', + 'com.endlessm.Updater.Error.WrongConfiguration', + 'com.endlessm.Updater.Error.NotOstreeSystem', + 'com.endlessm.Updater.Error.Cancelled') + def test_poll_early_error(self, error_name): + '''Test that a D-Bus error return from Poll() is handled correctly.''' + self.dbusmock.SetPollAction( + 'early-error', dbus.Dictionary({}, signature='sv'), + error_name, 'Some error message.') + + self.launch_gnome_software() + self.await_state(UpdaterState.ERROR) + + if error_name != 'com.endlessm.Updater.Error.Cancelled': + self.manual_check('Check there are no EOS updates listed, and a ' + 'GsPluginEosUpdater error is printed on the ' + 'terminal') + else: + self.manual_check('Check there are no EOS updates listed, and no ' + 'GsPluginEosUpdater cancellation error is ' + 'printed on the terminal') + + @ddt.data('com.endlessm.Updater.Error.WrongState', + 'com.endlessm.Updater.Error.LiveBoot', + 'com.endlessm.Updater.Error.WrongConfiguration', + 'com.endlessm.Updater.Error.NotOstreeSystem', + 'com.endlessm.Updater.Error.Cancelled') + def test_poll_late_error(self, error_name): + '''Test that a transition to the Error state after successfully calling + Poll() is handled correctly.''' + self.dbusmock.SetPollAction( + 'late-error', dbus.Dictionary({}, signature='sv'), + error_name, 'Some error message.') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + if error_name != 'com.endlessm.Updater.Error.Cancelled': + self.manual_check('Check there are no EOS updates listed, and a ' + 'GsPluginEosUpdater error is printed on the ' + 'terminal') + else: + self.manual_check('Check there are no EOS updates listed, and no ' + 'GsPluginEosUpdater cancellation error is ' + 'printed on the terminal') + self.await_state(UpdaterState.ERROR) + + @ddt.data(True, False) + def test_update_available(self, manually_refresh): + '''Test that the entire update process works if an update is + available.''' + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + self.dbusmock.SetFetchAction('success', '', '') + self.dbusmock.SetApplyAction('success', '', '') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + if manually_refresh: + self.manual_check('Check an EOS update is listed; press the ' + 'Refresh button') + + # TODO: if you proceed through the test slowly, this sometimes doesn’t + # work + self.manual_check('Check an EOS update is listed; press the Download ' + 'button') + self.await_state(UpdaterState.FETCHING) + self.dbusmock.FinishFetch() + + self.manual_check('Check the download has paused at ~75% complete ' + '(waiting to apply)') + self.await_state(UpdaterState.APPLYING_UPDATE) + self.dbusmock.FinishApply() + + self.manual_check('Check the banner says to ‘Restart Now’ (don’t ' + 'click it)') + self.await_state(UpdaterState.UPDATE_APPLIED) + + @ddt.data('com.endlessm.Updater.Error.WrongState', + 'com.endlessm.Updater.Error.WrongConfiguration', + 'com.endlessm.Updater.Error.Fetching', + 'com.endlessm.Updater.Error.MalformedAutoinstallSpec', + 'com.endlessm.Updater.Error.UnknownEntryInAutoinstallSpec', + 'com.endlessm.Updater.Error.FlatpakRemoteConflict', + 'com.endlessm.Updater.Error.MeteredConnection', + 'com.endlessm.Updater.Error.Cancelled') + def test_fetch_early_error(self, error_name): + '''Test that a D-Bus error return from Fetch() is handled correctly.''' + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + self.dbusmock.SetFetchAction('early-error', error_name, + 'Some error or other.') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + self.manual_check('Check an EOS update is listed; press the Download ' + 'button') + + if error_name != 'com.endlessm.Updater.Error.Cancelled': + self.manual_check('Check a fetch error is displayed') + else: + self.manual_check('Check no cancellation error is displayed') + + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + self.manual_check('Check an EOS update is listed again') + + @ddt.data('com.endlessm.Updater.Error.WrongState', + 'com.endlessm.Updater.Error.WrongConfiguration', + 'com.endlessm.Updater.Error.Fetching', + 'com.endlessm.Updater.Error.MalformedAutoinstallSpec', + 'com.endlessm.Updater.Error.UnknownEntryInAutoinstallSpec', + 'com.endlessm.Updater.Error.FlatpakRemoteConflict', + 'com.endlessm.Updater.Error.MeteredConnection', + 'com.endlessm.Updater.Error.Cancelled') + def test_fetch_late_error(self, error_name): + '''Test that a transition to the Error state after successfully calling + Fetch() is handled correctly.''' + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + self.dbusmock.SetFetchAction('late-error', error_name, + 'Some error or other.') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + self.manual_check('Check an EOS update is listed; press the Download ' + 'button') + self.await_state(UpdaterState.FETCHING) + self.dbusmock.FinishFetch() + + self.await_state(UpdaterState.ERROR) + if error_name != 'com.endlessm.Updater.Error.Cancelled': + self.manual_check('Check a fetch error is displayed') + else: + self.manual_check('Check no cancellation error is displayed') + + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + self.manual_check('Check an EOS update is listed again') + + @ddt.data('com.endlessm.Updater.Error.WrongState', + 'com.endlessm.Updater.Error.WrongConfiguration', + 'com.endlessm.Updater.Error.Cancelled') + def test_apply_early_error(self, error_name): + '''Test that a D-Bus error return from Apply() is handled correctly.''' + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + self.dbusmock.SetFetchAction('success', '', '') + self.dbusmock.SetApplyAction('early-error', error_name, + 'Some error or other.') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + self.manual_check('Check an EOS update is listed; press the Download ' + 'button') + self.await_state(UpdaterState.FETCHING) + self.dbusmock.FinishFetch() + + self.await_state(UpdaterState.ERROR) + if error_name != 'com.endlessm.Updater.Error.Cancelled': + self.manual_check('Check an apply error is displayed after the ' + 'update reached ~75% completion') + else: + self.manual_check('Check no cancellation error is displayed after ' + 'the update reached ~75% completion') + + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + self.manual_check('Check an EOS update is listed again') + + @ddt.data('com.endlessm.Updater.Error.WrongState', + 'com.endlessm.Updater.Error.WrongConfiguration', + 'com.endlessm.Updater.Error.Cancelled') + def test_apply_late_error(self, error_name): + '''Test that a transition to the Error state after successfully calling + Apply() is handled correctly.''' + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + self.dbusmock.SetFetchAction('success', '', '') + self.dbusmock.SetApplyAction('late-error', error_name, + 'Some error or other.') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + self.manual_check('Check an EOS update is listed; press the Download ' + 'button') + self.await_state(UpdaterState.FETCHING) + self.dbusmock.FinishFetch() + + self.manual_check('Check the download has paused at ~75% complete ' + '(waiting to apply)') + self.await_state(UpdaterState.APPLYING_UPDATE) + self.dbusmock.FinishApply() + + self.await_state(UpdaterState.ERROR) + if error_name != 'com.endlessm.Updater.Error.Cancelled': + self.manual_check('Check an apply error is displayed') + else: + self.manual_check('Check no cancellation error is displayed') + + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + self.manual_check('Check an EOS update is listed again') + + def test_no_eos_updater_running(self): + '''Test that the plugin doesn’t make a fuss if eos-updater is + unavailable.''' + self.p_mock.kill() + + self.launch_gnome_software() + + self.manual_check('Check there are no EOS updates listed, and no ' + 'errors shown') + + def test_fetch_ui_cancellation(self): + '''Test that cancelling a download from the UI works correctly.''' + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + self.dbusmock.SetFetchAction('success', '', '') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + self.manual_check('Check an EOS update is listed; press the Download ' + 'button, then shortly afterwards press the Cancel ' + 'button') + self.await_state(UpdaterState.FETCHING) + self.dbusmock.FinishFetch() + + self.await_state(UpdaterState.ERROR) + self.manual_check('Check a fetch cancellation error is displayed') + + def test_poll_eos_updater_dies(self): + '''Test that gnome-software recovers if eos-updater dies while + polling for updates.''' + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.p_mock.kill() + + self.manual_check('Check no error is shown for the poll failure') + self.setUp() + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + + self.manual_check('Press the Refresh button and check an update is ' + 'shown') + # TODO: It may take a few minutes for the update to appear on the + # updates page + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + def test_fetch_eos_updater_dies(self): + '''Test that gnome-software recovers if eos-updater dies while + fetching an update.''' + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + self.dbusmock.SetFetchAction('success', '', '') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + self.manual_check('Check an EOS update is listed; press the Download ' + 'button') + self.await_state(UpdaterState.FETCHING) + self.p_mock.kill() + + self.manual_check('Check an error is shown for the fetch failure') + + def test_apply_eos_updater_dies(self): + '''Test that gnome-software recovers if eos-updater dies while + applying an update.''' + self.dbusmock.SetPollAction( + 'update', dbus.Dictionary({}, signature='sv'), '', '') + self.dbusmock.SetFetchAction('success', '', '') + self.dbusmock.SetApplyAction('success', '', '') + + self.launch_gnome_software() + self.await_state(UpdaterState.POLLING) + self.dbusmock.FinishPoll() + + self.manual_check('Check an EOS update is listed; press the Download ' + 'button') + self.await_state(UpdaterState.FETCHING) + self.dbusmock.FinishFetch() + + self.manual_check('Check the download has paused at ~75% complete ' + '(waiting to apply)') + self.await_state(UpdaterState.APPLYING_UPDATE) + self.p_mock.kill() + + self.manual_check('Check an error is shown for the apply failure') + + +if __name__ == '__main__': + unittest.main() |