summaryrefslogtreecommitdiffstats
path: root/plugins/eos-updater
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--plugins/eos-updater/com.endlessm.Updater.xml292
-rw-r--r--plugins/eos-updater/gs-plugin-eos-updater.c1134
-rw-r--r--plugins/eos-updater/gs-plugin-eos-updater.h22
-rw-r--r--plugins/eos-updater/meson.build25
-rw-r--r--plugins/eos-updater/tests/eos_updater.py414
-rwxr-xr-xplugins/eos-updater/tests/manual-test.py434
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()