diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 985b0dbc..612a872c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,10 +15,8 @@ jobs: - name: Install Dependencies run: | apt update - apt install -y libaccountsservice-dev libdbus-1-dev libgranite-dev libgeoclue-2-dev meson valac + apt install -y libaccountsservice-dev libdbus-1-dev libgranite-dev libgeoclue-2-dev libpackagekit-glib2-dev libpolkit-gobject-1-dev meson valac - name: Build - env: - DESTDIR: out run: | meson build ninja -C build diff --git a/data/io.elementary.settings-daemon.policy.in b/data/io.elementary.settings-daemon.policy.in new file mode 100644 index 00000000..5f78c8a8 --- /dev/null +++ b/data/io.elementary.settings-daemon.policy.in @@ -0,0 +1,20 @@ + + + + elementary + https://elementary.io/ + + + Authentication is required to upgrade elementary OS + system-os-installer + + no + no + auth_admin_keep + + @PKGDATADIR@/io.elementary.settings-daemon.system-upgrade.helper + + + diff --git a/data/io.elementary.settings-daemon.system-upgrade.helper b/data/io.elementary.settings-daemon.system-upgrade.helper new file mode 100644 index 00000000..4116b066 --- /dev/null +++ b/data/io.elementary.settings-daemon.system-upgrade.helper @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +import argparse +import os +import re +import shutil + + +def update_third_party_repository(current, next, repository_file): + r = re.compile("[deb|deb-src].* ({}).*$".format(current)) + + if os.path.isfile(repository_file): + should_delete = False + should_update = False + with open(repository_file, "r") as f: + content = [] + for line in f.read().split("\n"): + if r.match(line): + should_update = True + line = line.replace(current, next) + content.append(line) + + if "ppa.launchpad.net/elementary-os" in line: + should_delete = True + elif "packages.elementary.io" in line: + should_delete = True + + if should_delete: + os.remove(repository_file) + + if should_update: + with open(repository_file, "w") as f: + f.write("\n".join(content)) + + +def install_files(next): + update_files = os.path.join( + "/", "usr", "share", "io.elementary.settings-daemon", "system-upgrades", next) + shutil.copytree(update_files, "/", dirs_exist_ok=True) + + +def main(): + parser = argparse.ArgumentParser( + description="Helper to upgrade elementary OS") + + parser.add_argument("--update-third-party-repository", action="store_true") + parser.add_argument("--install-files", action="store_true") + + parser.add_argument("--current", nargs="?", default=None) + parser.add_argument("--next", nargs="?", default=None) + parser.add_argument("--repository-file", nargs="?", default=None) + + args = parser.parse_args() + + if args.update_third_party_repository and args.current and args.next and args.repository_file: + return update_third_party_repository(args.current, args.next, args.repository_file) + elif args.install_files and args.next: + return install_files(args.next) + + +if __name__ == "__main__": + main() diff --git a/data/meson.build b/data/meson.build index 4db0d403..3a1cc626 100644 --- a/data/meson.build +++ b/data/meson.build @@ -34,3 +34,32 @@ i18n.merge_file( install: true, install_dir: join_paths(get_option('datadir'), 'metainfo'), ) + +conf = configuration_data() +conf.set('PKGDATADIR', pkgdatadir) +conf.set('GETTEXT_PACKAGE', meson.project_name()) + +gettext_declaration = configure_file( + configuration: conf, + input: meson.project_name() + '.policy.in', + output: meson.project_name() + '.policy.in' +) + +i18n.merge_file( + input: gettext_declaration, + output: meson.project_name() + '.policy', + po_dir: join_paths(meson.source_root(), 'po'), + install: true, + install_dir: polkit_actiondir +) + +install_subdir( + 'system-upgrades', + install_dir: pkgdatadir +) + +install_data( + meson.project_name() + '.system-upgrade.helper', + install_mode: 'r-xr--r--', + install_dir: pkgdatadir +) diff --git a/data/system-upgrades/jammy/etc/apt/sources.list.d/elementary.list b/data/system-upgrades/jammy/etc/apt/sources.list.d/elementary.list new file mode 100644 index 00000000..555895ab --- /dev/null +++ b/data/system-upgrades/jammy/etc/apt/sources.list.d/elementary.list @@ -0,0 +1,2 @@ +deb http://ppa.launchpad.net/elementary-os/stable/ubuntu jammy main +deb-src http://ppa.launchpad.net/elementary-os/stable/ubuntu jammy main diff --git a/data/system-upgrades/jammy/etc/apt/sources.list.d/patches.list b/data/system-upgrades/jammy/etc/apt/sources.list.d/patches.list new file mode 100644 index 00000000..c52be081 --- /dev/null +++ b/data/system-upgrades/jammy/etc/apt/sources.list.d/patches.list @@ -0,0 +1,2 @@ +deb http://ppa.launchpad.net/elementary-os/os-patches/ubuntu jammy main +deb-src http://ppa.launchpad.net/elementary-os/os-patches/ubuntu jammy main diff --git a/meson.build b/meson.build index fbf051ad..274a631c 100644 --- a/meson.build +++ b/meson.build @@ -4,6 +4,15 @@ project('io.elementary.settings-daemon', license: 'GPL3', ) +add_project_arguments( + '-DI_KNOW_THE_PACKAGEKIT_GLIB2_API_IS_SUBJECT_TO_CHANGE', + language:'c' +) + +prefix = get_option('prefix') +datadir = join_paths(prefix, get_option('datadir')) +pkgdatadir = join_paths(datadir, meson.project_name()) + gio_dep = dependency ('gio-2.0') glib_dep = dependency('glib-2.0') granite_dep = dependency('granite', version: '>= 5.3.0') @@ -12,6 +21,11 @@ i18n = import('i18n') cc = meson.get_compiler('c') m_dep = cc.find_library('m', required : false) libgeoclue_dep = dependency ('libgeoclue-2.0') +packagekit_dep = dependency('packagekit-glib2') +polkit_dep = dependency('polkit-gobject-1') +posix_dep = meson.get_compiler('vala').find_library('posix') + +polkit_actiondir = polkit_dep.get_pkgconfig_variable('actiondir', define_variable: ['prefix', prefix]) conf_data = configuration_data() conf_data.set('PROJECT_NAME', meson.project_name()) diff --git a/po/POTFILES b/po/POTFILES index 31a08460..1b2b0b33 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -1 +1,2 @@ +data/io.elementary.settings-daemon.policy.in data/settings-daemon.appdata.xml.in diff --git a/src/Application.vala b/src/Application.vala index 39c8a5e1..eee06dcf 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -128,6 +128,24 @@ public class SettingsDaemon.Application : GLib.Application { } } + public override bool dbus_register (DBusConnection connection, string object_path) throws Error { + // We must chain up to the parent class: + base.dbus_register (connection, object_path); + + // Now we can do our own stuff here. For example, we could export some D-Bus objects + connection.register_object (object_path, new Backends.SystemUpgrade ()); + + return true; + } + + public override void dbus_unregister (DBusConnection connection, string object_path) { + // Do our own stuff here, e.g. unexport any D-Bus objects we exported in the dbus_register + // hook above. Be sure to check that we actually did export them, since the hook + // above might have returned early due to the parent class' hook returning false! + + base.dbus_unregister (connection, object_path); + } + public static int main (string[] args) { var application = new Application (); return application.run (args); diff --git a/src/Backends/SystemUpgrade.vala b/src/Backends/SystemUpgrade.vala new file mode 100644 index 00000000..6f6235ce --- /dev/null +++ b/src/Backends/SystemUpgrade.vala @@ -0,0 +1,224 @@ +/* + * Copyright 2022 elementary, Inc. (https://elementary.io) + * + * 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 3 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 + * Authored by: Marius Meisenzahl + */ + +[DBus (name = "io.elementary.SystemUpgrade")] +public class SettingsDaemon.Backends.SystemUpgrade : GLib.Object { + public bool system_upgrade_available { + get { + return true; + } + } + + public signal void system_upgrade_progress (int percentage); + + public signal void system_upgrade_finished (); + + public signal void system_upgrade_failed (string text); + + public signal void system_upgrade_cancelled (); + + public void start_upgrade () throws Error { + upgrade_async.begin (); + } + + public void cancel () throws Error { + cancellable.cancel (); + + if (is_system_upgrade_running) { + is_system_upgrade_running = false; + } + } + + private async void upgrade_async () { + if (is_system_upgrade_running) { + return; + } + + if (cancellable.is_cancelled ()) { + cancellable.reset (); + } + + is_system_upgrade_running = true; + + Inhibitor.get_instance ().inhibit (); + + Pk.Results? results = null; + + try { + debug ("Refresh cache"); + results = yield task.refresh_cache_async (true, cancellable, (t, p) => { }); + + if (cancellable.is_cancelled ()) { + Inhibitor.get_instance ().uninhibit (); + return; + } + + debug ("Get repositories"); + results = yield task.get_repo_list_async (Pk.Bitfield.from_enums (Pk.Filter.NONE), cancellable, (p, t) => { }); + + if (cancellable.is_cancelled ()) { + Inhibitor.get_instance ().uninhibit (); + return; + } + + var repo_files = new Array (); + var repos = results.get_repo_detail_array (); + for (int i = 0; i < repos.length; i++) { + var repo = repos[i]; + + // TODO: check for ppas + + var parts = repo.repo_id.split (":", 2); + var f = parts[0]; + + if (!FileUtils.test (f, FileTest.EXISTS)) { + continue; + } + + bool found = false; + for (int j = 0; j < repo_files.length; j++) { + if (repo_files.index (j) == f) { + found = true; + break; + } + } + + if (!found) { + repo_files.append_val (f); + } + } + + var helper = new Utils.SystemUpgradeHelper (); + + debug ("Update repositories"); + for (int i = 0; i < repo_files.length; i++) { + var repository_file = repo_files.index (i); + debug (" %s", repository_file); + + if (!helper.update_third_party_repository ("focal", "jammy", repository_file)) { + throw new Error (0, 0, "Could not update repository: %s\n", repository_file); + } + } + + if (!helper.install_files ("jammy")) { + throw new Error (0, 0, "Could not install files\n"); + } + + debug ("Refresh cache"); + results = yield task.refresh_cache_async (true, cancellable, (p, t) => { }); + + if (cancellable.is_cancelled ()) { + Inhibitor.get_instance ().uninhibit (); + return; + } + + if (results == null) { + throw new Error (0, 0, "Could not refresh cache"); + } + + debug ("Get updates"); + results = yield task.get_updates_async (Pk.Bitfield.from_enums (Pk.Filter.NEWEST), cancellable, (t, p) => { }); + + if (cancellable.is_cancelled ()) { + Inhibitor.get_instance ().uninhibit (); + return; + } + + if (results == null) { + throw new Error (0, 0, "Could not get updates"); + } + + var sack = results.get_package_sack (); + sack.remove_by_filter (update_system_filter_helper); + var package_ids = sack.get_ids (); + + task.only_download = true; + + debug ("Download packages"); + var status = Pk.Status.UNKNOWN; + int percentage = -1; + results = yield task.update_packages_async (package_ids, cancellable, ((p, t) => { + if (t == Pk.ProgressType.STATUS) { + status = p.get_status (); + } + + int new_percentage = percentage; + if (t == Pk.ProgressType.PERCENTAGE && status == Pk.Status.DOWNLOAD) { + new_percentage = p.percentage; + } + + if (status == Pk.Status.FINISHED) { + new_percentage = 100; + } + + if (new_percentage != percentage) { + percentage = new_percentage; + system_upgrade_progress (percentage); + } + })); + + if (cancellable.is_cancelled ()) { + Inhibitor.get_instance ().uninhibit (); + return; + } + + if (results == null) { + throw new Error (0, 0, "Could not download packages"); + } + + debug ("Set PackageKit reboot action"); + Pk.offline_trigger (Pk.OfflineAction.REBOOT, cancellable); + + if (cancellable.is_cancelled ()) { + Inhibitor.get_instance ().uninhibit (); + return; + } + + debug ("Ready to reboot"); + + system_upgrade_finished (); + } catch (Error e) { + warning ("Upgrade failed: %s", e.message); + + system_upgrade_failed (e.message); + } + + is_system_upgrade_running = false; + + Inhibitor.get_instance ().uninhibit (); + } + + construct { + task = new Pk.Task (); + cancellable = new Cancellable (); + + cancellable.cancelled.connect (() => { system_upgrade_cancelled (); }); + } + + private static Pk.Task task; + private static Cancellable cancellable; + + private bool is_system_upgrade_running = false; + + private bool update_system_filter_helper (Pk.Package package) { + var info = package.get_info (); + return (info != Pk.Info.OBSOLETING && info != Pk.Info.REMOVING); + } +} diff --git a/src/Utils/Inhibitor.vala b/src/Utils/Inhibitor.vala new file mode 100644 index 00000000..8221f0ff --- /dev/null +++ b/src/Utils/Inhibitor.vala @@ -0,0 +1,102 @@ +/*- + * Copyright (c) 2016 elementary LLC. (https://elementary.io) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Library General Public License as published by + * the Free Software Foundation, either version 2.1 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 Library General Public License for more details. + * + * You should have received a copy of the GNU Library General Public License + * along with this program. If not, see . + */ + +[DBus (name = "org.freedesktop.ScreenSaver")] +public interface ScreenSaverIface : Object { + public abstract uint32 inhibit (string app_name, string reason) throws Error; + public abstract void un_inhibit (uint32 cookie) throws Error; + public abstract void simulate_user_activity () throws Error; +} + +public class Inhibitor : Object { + private const string IFACE = "org.freedesktop.ScreenSaver"; + private const string IFACE_PATH = "/ScreenSaver"; + + private static Inhibitor? instance = null; + + private uint32? inhibit_cookie = null; + + private ScreenSaverIface? screensaver_iface = null; + + private bool inhibited = false; + private bool simulator_started = false; + + private Inhibitor () { + try { + screensaver_iface = Bus.get_proxy_sync (BusType.SESSION, IFACE, IFACE_PATH, DBusProxyFlags.NONE); + } catch (Error e) { + warning ("Could not start screensaver interface: %s", e.message); + } + } + + public static Inhibitor get_instance () { + if (instance == null) { + instance = new Inhibitor (); + } + + return instance; + } + + public void inhibit () { + if (screensaver_iface != null && !inhibited) { + try { + inhibited = true; + inhibit_cookie = screensaver_iface.inhibit ("Installer", "Installing"); + simulate_activity (); + debug ("Inhibiting screen"); + } catch (Error e) { + warning ("Could not inhibit screen: %s", e.message); + } + } + } + + public void uninhibit () { + if (screensaver_iface != null && inhibited) {//&& inhibit_cookie != null) { + try { + inhibited = false; + screensaver_iface.un_inhibit (inhibit_cookie); + debug ("Uninhibiting screen"); + } catch (Error e) { + warning ("Could not uninhibit screen: %s", e.message); + } + } + } + + /* + * Inhibit currently does not block a suspend from ocurring, + * so we simulate user activity every 2 mins to prevent it + */ + private void simulate_activity () { + if (simulator_started) return; + + simulator_started = true; + Timeout.add_full (Priority.DEFAULT, 120000, ()=> { + if (inhibited) { + try { + debug ("Simulating activity"); + screensaver_iface.simulate_user_activity (); + } catch (Error e) { + warning ("Could not simulate user activity: %s", e.message); + } + } else { + simulator_started = false; + } + + return inhibited; + }); + } +} diff --git a/src/Utils/SystemUpgradeHelper.vala b/src/Utils/SystemUpgradeHelper.vala new file mode 100644 index 00000000..314d8ea0 --- /dev/null +++ b/src/Utils/SystemUpgradeHelper.vala @@ -0,0 +1,176 @@ +/* + * Copyright 2022 elementary, Inc. (https://elementary.io) + * + * 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 3 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 + * Authored by: Marius Meisenzahl + */ + +public class SettingsDaemon.Utils.SystemUpgradeHelper : GLib.Object { + public bool update_third_party_repository (string current, string next, string repository_file) { + if (!authenticate ()) { + var message = "Unable to authenticate"; + warning (message); + on_standard_error (message); + return false; + } + + var cmd = "update-third-party-repository"; + + var command = "pkexec %s/io.elementary.settings-daemon.system-upgrade.helper --%s --current %s --next %s --repository-file %s".printf ( + "/usr/share/io.elementary.settings-daemon", cmd, current, next, repository_file); + + if (!run (command)) { + on_error (); + return false; + } + + return true; + } + + public bool install_files (string next) { + if (!authenticate ()) { + var message = "Unable to authenticate"; + warning (message); + on_standard_error (message); + return false; + } + + var cmd = "install-files"; + + var command = "pkexec %s/io.elementary.settings-daemon.system-upgrade.helper --%s --next %s".printf ( + "/usr/share/io.elementary.settings-daemon", cmd, next); + + if (!run (command)) { + on_error (); + return false; + } + + return true; + } + + public signal void on_standard_output (string line); + public signal void on_standard_error (string line); + public signal void on_error (); + + private static Polkit.Permission? permission = null; + + private bool authenticate () { + if (permission == null) { + try { + permission = new Polkit.Permission.sync ( + "io.elementary.settings-daemon.system-upgrade", + new Polkit.UnixProcess (Posix.getpid ()) + ); + } catch (Error e) { + warning ("Can't get permission to upgrade without prompting for admin: %s", e.message); + return false; + } + } + + try { + if (!permission.allowed) { + permission.acquire (null); + } + } catch (Error e) { + warning ("Can't get permission to upgrade without prompting for admin: %s", e.message); + return false; + } + + return permission.allowed; + } + + private bool process_line (IOChannel channel, IOCondition condition, string stream_name) { + if (condition == IOCondition.HUP) { + // debug ("%s: The fd has been closed.", stream_name); + return false; + } + + try { + string line; + channel.read_line (out line, null, null); + + switch (stream_name) { + case "stdout": + // debug ("%s", line); + on_standard_output (line); + break; + case "stderr": + // warning ("\033[0;31m%s\033[0m", line); + on_standard_error (line); + break; + } + } catch (IOChannelError e) { + // warning ("%s: IOChannelError: %s", stream_name, e.message); + return false; + } catch (ConvertError e) { + // warning ("%s: ConvertError: %s", stream_name, e.message); + return false; + } + + return true; + } + + private bool run (string cmd) { + MainLoop loop = new MainLoop (); + try { + string[] spawn_args = cmd.split (" "); + string[] spawn_env = Environ.get (); + Pid child_pid; + + int standard_input; + int standard_output; + int standard_error; + + bool exit_status = false; + + Process.spawn_async_with_pipes ("/", + spawn_args, + spawn_env, + SpawnFlags.SEARCH_PATH | SpawnFlags.DO_NOT_REAP_CHILD, + null, + out child_pid, + out standard_input, + out standard_output, + out standard_error); + + // stdout: + IOChannel output = new IOChannel.unix_new (standard_output); + output.add_watch (IOCondition.IN | IOCondition.HUP, (channel, condition) => { + return process_line (channel, condition, "stdout"); + }); + + // stderr: + IOChannel error = new IOChannel.unix_new (standard_error); + error.add_watch (IOCondition.IN | IOCondition.HUP, (channel, condition) => { + return process_line (channel, condition, "stderr"); + }); + + ChildWatch.add (child_pid, (pid, status) => { + // Triggered when the child indicated by child_pid exits + Process.close_pid (pid); + exit_status = (status == 0); + loop.quit (); + }); + + loop.run (); + + return exit_status; + } catch (Error e) { + // warning ("Error: %s", e.message); + return false; + } + } +} diff --git a/src/meson.build b/src/meson.build index 7c726d97..baf43b2b 100644 --- a/src/meson.build +++ b/src/meson.build @@ -6,7 +6,10 @@ sources = files( 'Backends/KeyboardSettings.vala', 'Backends/MouseSettings.vala', 'Backends/PrefersColorSchemeSettings.vala', + 'Backends/SystemUpgrade.vala', + 'Utils/Inhibitor.vala', 'Utils/SunriseSunsetCalculator.vala', + 'Utils/SystemUpgradeHelper.vala', ) executable( @@ -19,6 +22,9 @@ executable( granite_dep, libgeoclue_dep, m_dep, + packagekit_dep, + polkit_dep, + posix_dep, ], install: true, )