diff --git a/po/POTFILES b/po/POTFILES index 064d75e7..c51f8465 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -2,6 +2,9 @@ data/settings-daemon.metainfo.xml.in src/Application.vala src/Backends/UbuntuDrivers.vala src/Backends/SystemUpdate/PackageKit.vala +src/Backends/SystemUpdate/SystemDSystemUpdate.vala src/Backends/SystemUpdate/SystemUpdate.vala src/Backends/SystemUpdate/SystemUpdateProvider.vala src/Utils/PkUtils.vala +src/Utils/SysupdateJob.vala +src/Utils/SysupdateTarget.vala diff --git a/src/Backends/SystemUpdate/SystemDSystemUpdate.vala b/src/Backends/SystemUpdate/SystemDSystemUpdate.vala new file mode 100644 index 00000000..4bc2f1ee --- /dev/null +++ b/src/Backends/SystemUpdate/SystemDSystemUpdate.vala @@ -0,0 +1,154 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class SettingsDaemon.Backends.SystemDSystemUpdate : Object, SystemUpdateProvider { + private const string NOTIFICATION_ID = "system-update"; + + private PkUtils.CurrentState current_state; + private UpdateDetails update_details; + + private Utils.SysupdateTarget target; + private Cancellable? current_cancellable; + + construct { + current_state = { + UP_TO_DATE, + "", + 0, + 0 + }; + + update_details = { + {}, + 0, + {} + }; + + target = new Utils.SysupdateTarget (Sysupdate.Target.HOST_PATH); + } + + public async void check_for_updates (bool force, bool notify) throws DBusError, IOError { + if (SettingsDaemon.Utils.is_running_in_demo_mode () && !force) { + return; + } + + if (current_state.state != UP_TO_DATE && current_state.state != AVAILABLE && !force) { + return; + } + + update_state (CHECKING, _("Checking for updates")); + + /* First check if there is an even newer version than anything we have installed */ + string? new_version = null; + try { + new_version = yield target.check_new (); + } catch (Error e) { + critical ("Failed to check for updates: %s", e.message); + update_state (UP_TO_DATE); + return; + } + + if (new_version != null) { + update_details = { + { new_version }, + 0, + { Pk.Info.IMPORTANT } + }; + + update_state (AVAILABLE); + return; + } + + /* Then check if we maybe have a newer version already installed but not booted into */ + try { + if (yield target.has_newer_unapplied_version ()) { + update_state (RESTART_REQUIRED); + return; + } + } catch (Error e) { + critical ("Failed to check for newer unapplied version: %s", e.message); + } + + update_state (UP_TO_DATE); + } + + public async void update () throws DBusError, IOError { + if (current_state.state != AVAILABLE) { + return; + } + + update_state (DOWNLOADING); + + current_cancellable = new Cancellable (); + + try { + yield target.update (NEWEST, current_cancellable, progress_callback); + } catch (Error e) { + critical ("Failed to update: %s", e.message); + send_error (e.message); + return; + } + + update_state (RESTART_REQUIRED); + } + + public void cancel () throws DBusError, IOError { + if (current_state.state != DOWNLOADING) { + return; + } + + current_cancellable.cancel (); + } + + private void progress_callback (string message, uint percentage) { + update_state ( + DOWNLOADING, + message, + percentage, + 0 + ); + } + + private void send_error (string message) { + var notification = new Notification (_("System updates couldn't be installed")); + notification.set_body (_("An error occurred while trying to update your system")); + notification.set_icon (new ThemedIcon ("dialog-error")); + notification.set_default_action (Application.ACTION_PREFIX + Application.SHOW_UPDATES_ACTION); + + GLib.Application.get_default ().send_notification (NOTIFICATION_ID, notification); + + update_state (ERROR, message); + } + + private void update_state ( + PkUtils.State state, + string message = "", + uint percentage = 0, + uint64 download_size_remaining = 0 + ) { + current_state = { + state, + message, + percentage, + download_size_remaining + }; + + state_changed (); + } + + public async PkUtils.CurrentState get_current_state () throws DBusError, IOError { + return current_state; + } + + public async UpdateDetails get_update_details () throws DBusError, IOError { + return update_details; + } + + public async int64 get_last_refresh_time () throws DBusError, IOError { + return 0; + } +} diff --git a/src/Backends/SystemUpdate/SystemUpdate.vala b/src/Backends/SystemUpdate/SystemUpdate.vala index 4dcc838e..6b9f2843 100644 --- a/src/Backends/SystemUpdate/SystemUpdate.vala +++ b/src/Backends/SystemUpdate/SystemUpdate.vala @@ -13,10 +13,11 @@ public class SettingsDaemon.Backends.SystemUpdate : Object { construct { if (SettingsDaemon.Utils.is_sysupdate ()) { - return; + provider = new SystemDSystemUpdate (); + } else { + provider = new PackageKit (); } - provider = new PackageKit (); provider.state_changed.connect (() => state_changed ()); } diff --git a/src/DBus/Sysupdate.vala b/src/DBus/Sysupdate.vala new file mode 100644 index 00000000..55ba280b --- /dev/null +++ b/src/DBus/Sysupdate.vala @@ -0,0 +1,56 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +/** + * Some of the DBus interfaces here have manually implemented methods. + * The reason for that is that most of the methods are authenticated via polkit therefore + * we need to call them with ALLOW_INTERACTIVE_AUTHORIZATION which currently isn't possible + * via the normal vala way. Therefore all methods that are authenticated via polkit + * (even if they are by default allowed without privileges e.g. check_new) are manually + * implemented. + */ +namespace Sysupdate { + public const string BUS_NAME = "org.freedesktop.sysupdate1"; +} + +[DBus (name = "org.freedesktop.sysupdate1.Manager")] +private interface Sysupdate.Manager : Object { + public const string PATH = "/org/freedesktop/sysupdate1"; + + public signal void job_removed (uint64 job_id, ObjectPath object_path, int status); +} + +[DBus (name = "org.freedesktop.sysupdate1.Target")] +private interface Sysupdate.Target : DBusProxy { + public const string HOST_PATH = "/org/freedesktop/sysupdate1/target/host"; + + public async string check_new () throws Error { + var result = yield call ("CheckNew", null, ALLOW_INTERACTIVE_AUTHORIZATION, -1); + return (string) result.get_child_value (0); + } + + public abstract async string get_version () throws DBusError, IOError; + + public async void update (string new_version, uint64 flags, out string used_version, out uint64 job_id, out ObjectPath job_path) throws Error { + var parameters = new Variant.tuple ({ new_version, flags }); + + var result = yield call ("Update", parameters, ALLOW_INTERACTIVE_AUTHORIZATION, -1); + + used_version = (string) result.get_child_value (0); + job_id = (uint64) result.get_child_value (1); + job_path = (ObjectPath) result.get_child_value (2); + } +} + +[DBus (name = "org.freedesktop.sysupdate1.Job")] +private interface Sysupdate.Job : DBusProxy { + public abstract uint progress { get; } + + public async void cancel () throws Error { + yield call ("Cancel", null, ALLOW_INTERACTIVE_AUTHORIZATION, -1); + } +} diff --git a/src/Utils/SysupdateJob.vala b/src/Utils/SysupdateJob.vala new file mode 100644 index 00000000..e0108d3a --- /dev/null +++ b/src/Utils/SysupdateJob.vala @@ -0,0 +1,54 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class SettingsDaemon.Utils.SysupdateJob : Object { + public ObjectPath path { get; construct; } + + private Cancellable cancellable; + private unowned SysupdateTarget.ProgressCallback progress_callback; + + private Sysupdate.Job? job; + + /** + * Starts observing the job at the given path, cancelling it when the cancellable is triggered + * and calling the progress callback with progress updates. + */ + public async SysupdateJob (ObjectPath path, Cancellable cancellable, SysupdateTarget.ProgressCallback progress_callback) throws Error { + Object (path: path); + + this.cancellable = cancellable; + this.progress_callback = progress_callback; + + job = yield Bus.get_proxy (SYSTEM, Sysupdate.BUS_NAME, path, NONE); + job.g_properties_changed.connect (on_properties_changed); + + cancellable.cancelled.connect (cancel); + + if (cancellable.is_cancelled ()) { + /* The user tried to cancel while the job was being started */ + cancel (); + } + } + + private void on_properties_changed () requires (job != null) { + progress_callback (_("Downloading new image"), job.progress); + } + + private void cancel () requires (job != null) { + job.cancel.begin ((obj, res) => { + try { + job.cancel.end (res); + } catch (Error e) { + warning ("Failed to cancel job: %s", e.message); + } + }); + } + + internal void notify_completed () { + job = null; + } +} diff --git a/src/Utils/SysupdateJobManager.vala b/src/Utils/SysupdateJobManager.vala new file mode 100644 index 00000000..36c1358a --- /dev/null +++ b/src/Utils/SysupdateJobManager.vala @@ -0,0 +1,68 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class SettingsDaemon.Utils.SysupdateJobManager : Object { + private Sysupdate.Manager manager; + + private HashTable received_object_paths; + + public async SysupdateJobManager (Cancellable cancellable) throws Error { + manager = yield Bus.get_proxy (SYSTEM, Sysupdate.BUS_NAME, Sysupdate.Manager.PATH, NONE, cancellable); + manager.job_removed.connect (on_job_removed); + } + + construct { + received_object_paths = new HashTable (str_hash, str_equal); + } + + private void on_job_removed (uint64 finished_job_id, string finished_object_path, int status) { + received_object_paths[finished_object_path] = status; + } + + /** + * Waits for the job to complete or returns immediately if the job has already completed. + * If the job has failed an error will be thrown. + * Note that for this to reliably work without races the this has to have been created before + * the job was started. + */ + public async void wait_for_job (SysupdateJob job) throws Error { + if (job.path in received_object_paths) { + check_status (job); + return; + } + + var signal_id = manager.job_removed.connect ((job_id, job_path) => { + if (job_path == job.path) { + wait_for_job.callback (); + } + }); + + yield; + + manager.disconnect (signal_id); + + check_status (job); + } + + private void check_status (SysupdateJob job) throws Error { + job.notify_completed (); + + var status = received_object_paths[job.path]; + + if (status == 0) { + return; + } + + if (status > 0) { + throw new IOError.FAILED ("Job failed with exit code: %d".printf (status)); + } + + if (status < 0) { + throw IOError.from_errno (-status); + } + } +} diff --git a/src/Utils/SysupdateTarget.vala b/src/Utils/SysupdateTarget.vala new file mode 100644 index 00000000..95e68e21 --- /dev/null +++ b/src/Utils/SysupdateTarget.vala @@ -0,0 +1,88 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class SettingsDaemon.Utils.SysupdateTarget : Object { + public enum UpdateVersion { + NEWEST, + CURRENT, + } + + public delegate void ProgressCallback (string message, uint percentage); + + public string path { get; construct; } + + private Sysupdate.Target? target; + + public SysupdateTarget (string path) { + Object (path: path); + } + + private async void ensure_connected () throws Error { + if (target != null) { + return; + } + + target = yield Bus.get_proxy (SYSTEM, Sysupdate.BUS_NAME, path, NONE, null); + } + + public async string? check_new () throws Error { + yield ensure_connected (); + + var new_version = yield target.check_new (); + + if (new_version == "") { + /* Docs say "" means no new version is available so make it clearer by returning null */ + return null; + } + + return new_version; + } + + /** + * Runs an update. This is both used to actually update the system to a newer version + * but also to apply newly selected or deselected features. + * You can specify what version you want to use with {@link version}. + */ + public async void update (UpdateVersion version, Cancellable cancellable, ProgressCallback progress_callback) throws Error { + progress_callback (_("Preparing update"), 0); + + yield ensure_connected (); + + /* "" means newest available so we use it when version is UpdateVersion.NEWEST */ + string version_string = ""; + + if (version == CURRENT) { + version_string = yield target.get_version (); + } + + var manager = yield new SysupdateJobManager (cancellable); + + progress_callback (_("Starting update"), 0); + + string used_version; + uint64 job_id; + ObjectPath job_path; + yield target.update (version_string, 0, out used_version, out job_id, out job_path); + + var job = yield new SysupdateJob (job_path, cancellable, progress_callback); + + yield manager.wait_for_job (job); + } + + /** + * Checks if there is a new version already downloaded and installed but not booted into. + * Most commonly this means we ran update but haven't rebooted into the new version yet. + */ + public async bool has_newer_unapplied_version () throws Error { + yield ensure_connected (); + + var newest_installed_version = yield target.get_version (); + var booted_version = Environment.get_os_info ("IMAGE_VERSION"); + + return newest_installed_version != booted_version; + } +} diff --git a/src/meson.build b/src/meson.build index c0fd3128..196c8c8a 100644 --- a/src/meson.build +++ b/src/meson.build @@ -11,13 +11,18 @@ sources = files( 'Backends/PowerProfilesSync.vala', 'Backends/PrefersColorSchemeSettings.vala', 'Backends/SystemUpdate/PackageKit.vala', + 'Backends/SystemUpdate/SystemDSystemUpdate.vala', 'Backends/SystemUpdate/SystemUpdate.vala', 'Backends/SystemUpdate/SystemUpdateProvider.vala', 'DBus/DesktopIntegration.vala', 'DBus/ShellKeyGrabber.vala', + 'DBus/Sysupdate.vala', 'Utils/PkUtils.vala', 'Utils/SessionUtils.vala', 'Utils/SunriseSunsetCalculator.vala', + 'Utils/SysupdateJob.vala', + 'Utils/SysupdateJobManager.vala', + 'Utils/SysupdateTarget.vala', ) args = []