diff --git a/data/org.frostyard.FirstSetup.policy b/data/org.frostyard.FirstSetup.policy index c43e696..3d9a7d8 100644 --- a/data/org.frostyard.FirstSetup.policy +++ b/data/org.frostyard.FirstSetup.policy @@ -116,6 +116,17 @@ /usr/share/org.frostyard.FirstSetup/snow_first_setup/scripts/recovery-key + + FirstSetup sysext feature enablement script + Enable and download optional system extensions + + no + no + auth_admin + + /usr/share/org.frostyard.FirstSetup/snow_first_setup/scripts/sysext-features + + FirstSetup system flatpak installation script Install system flatpak applications diff --git a/po/POTFILES b/po/POTFILES index d0cf705..9e1f976 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -18,6 +18,7 @@ snow_first_setup/gtk/keyboard.ui snow_first_setup/gtk/widget-location-list-page.ui snow_first_setup/gtk/layout-applications.ui snow_first_setup/gtk/applications-dialog.ui +snow_first_setup/gtk/sysext.ui snow_first_setup/__init__.py @@ -39,6 +40,7 @@ snow_first_setup/views/timezone.py snow_first_setup/views/welcome_user.py snow_first_setup/views/welcome.py snow_first_setup/views/progress.py +snow_first_setup/views/sysext.py snow_first_setup/views/logout.py snow_first_setup/views/done.py diff --git a/snow_first_setup/core/backend.py b/snow_first_setup/core/backend.py index 54bbb79..fc4ef97 100644 --- a/snow_first_setup/core/backend.py +++ b/snow_first_setup/core/backend.py @@ -118,6 +118,93 @@ def mark_setup_complete(): def _setup_system(): return run_script("setup-system", []) +# Directories scanned by updex for .feature definitions, in precedence order +# (an /etc override wins over the /usr/lib default shipped in the image). +_SYSUPDATE_DIRS = [ + "/etc/sysupdate.d", + "/run/sysupdate.d", + "/usr/local/lib/sysupdate.d", + "/usr/lib/sysupdate.d", +] + +def _parse_feature_file(path: str) -> dict: + """Parse a .feature file (or drop-in), returning only the keys present.""" + feature = {} + with open(path, "r", encoding="utf-8") as file: + for line in file: + line = line.strip() + if "=" not in line or line.startswith(("#", ";", "[")): + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if key == "Description": + feature["description"] = value + elif key == "Documentation": + feature["documentation"] = value + elif key == "Enabled": + feature["enabled"] = value.lower() in ("true", "yes", "1") + return feature + +def _feature_dropins(name: str) -> list[str]: + """Drop-in paths for a feature, in application order. + + A same-named drop-in in a higher-precedence directory masks the lower + one; the surviving set applies sorted by file name (updex writes the + enablement as .feature.d/00-updex.conf in /etc/sysupdate.d). + """ + dropins = {} + for directory in reversed(_SYSUPDATE_DIRS): + dropin_dir = os.path.join(directory, name + ".feature.d") + try: + entries = os.listdir(dropin_dir) + except OSError: + continue + for entry in entries: + if entry.endswith(".conf"): + dropins[entry] = os.path.join(dropin_dir, entry) + return [dropins[entry] for entry in sorted(dropins)] + +def list_sysext_features() -> list[dict]: + """Discover optional sysext features from *.feature files. + + Returns a sorted list of {"name", "description", "documentation", + "enabled"} dicts, empty when the image ships no feature definitions. + """ + features = {} + # Lowest-precedence directory first so a .feature file in e.g. /etc + # replaces the one shipped in /usr/lib. + for directory in reversed(_SYSUPDATE_DIRS): + try: + entries = sorted(os.listdir(directory)) + except OSError: + continue + for entry in entries: + if not entry.endswith(".feature"): + continue + name = entry[: -len(".feature")] + try: + features[name] = _parse_feature_file(os.path.join(directory, entry)) + except OSError as e: + logger.warning(f"Could not read feature file {entry}: {e}") + result = [] + for name, feature in features.items(): + for dropin in _feature_dropins(name): + try: + feature.update(_parse_feature_file(dropin)) + except OSError as e: + logger.warning(f"Could not read feature drop-in {dropin}: {e}") + result.append({ + "name": name, + "description": feature.get("description") or name, + "documentation": feature.get("documentation", ""), + "enabled": feature.get("enabled", False), + }) + return sorted(result, key=lambda feature: feature["name"]) + +def _enable_sysext(name: str): + return run_script("sysext-features", [name], root=True) + def _install_flatpak(id: str): return run_script("flatpak", [id]) @@ -259,6 +346,16 @@ def install_flatpak(): _deferred_actions[uid] = {"action_id": action_id, "callback": install_flatpak, "info": action_info} report_progress(action_id, uid, ProgressState.Initialized, action_info) +def enable_sysext_deferred(name: str, description: str): + global _deferred_actions + action_id = "enable_sysext" + uid = action_id+name + action_info = {"feature_name": name, "feature_description": description} + def enable_sysext(): + _run_function_with_progress(action_id, uid, action_info, _enable_sysext, name) + _deferred_actions[uid] = {"action_id": action_id, "callback": enable_sysext, "info": action_info} + report_progress(action_id, uid, ProgressState.Initialized, action_info) + def _run_function_with_progress(action_id: str, uid: str, action_info: dict, function, *args): report_progress(action_id, uid, ProgressState.Running, action_info) success = function(*args) @@ -275,6 +372,14 @@ def clear_flatpak_deferred(): new_list[uid] = action _deferred_actions = new_list +def clear_sysext_deferred(): + global _deferred_actions + new_list = {} + for uid, action in _deferred_actions.items(): + if action["action_id"] != "enable_sysext": + new_list[uid] = action + _deferred_actions = new_list + def start_deferred_actions(): global _deferred_actions for _, action in _deferred_actions.items(): diff --git a/snow_first_setup/gtk/sysext.ui b/snow_first_setup/gtk/sysext.ui new file mode 100644 index 0000000..8a489e1 --- /dev/null +++ b/snow_first_setup/gtk/sysext.ui @@ -0,0 +1,52 @@ + + + + + + diff --git a/snow_first_setup/scripts/meson.build b/snow_first_setup/scripts/meson.build index e392180..52cd18f 100755 --- a/snow_first_setup/scripts/meson.build +++ b/snow_first_setup/scripts/meson.build @@ -23,6 +23,7 @@ sources = [ 'enroll-key', 'recovery-key', 'oemcomplete', + 'sysext-features', ] install_data(sources, install_dir: scriptsdir) diff --git a/snow_first_setup/scripts/sysext-features b/snow_first_setup/scripts/sysext-features new file mode 100755 index 0000000..cd86e73 --- /dev/null +++ b/snow_first_setup/scripts/sysext-features @@ -0,0 +1,21 @@ +#!/bin/bash +if [ -z "$1" ]; then + echo "usage:" + echo "sysext-features " + exit 5 +fi + +if ! [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "invalid feature name: $1" + exit 6 +fi + +if ! [ "$UID" == "0" ]; then + echo "this script must be run with super user privileges" + exit 7 +fi + +# Enable the feature (writes the enablement to /etc/sysupdate.d) and download +# its extension images right away; updex runs systemd-sysext refresh after the +# install so services activate before the user's first login. +updex --silent features enable "$1" --now diff --git a/snow_first_setup/snow-first-setup.gresource.xml b/snow_first_setup/snow-first-setup.gresource.xml index de195d4..0eca5a2 100644 --- a/snow_first_setup/snow-first-setup.gresource.xml +++ b/snow_first_setup/snow-first-setup.gresource.xml @@ -29,6 +29,7 @@ gtk/layout-applications.ui gtk/applications-dialog.ui + gtk/sysext.ui assets/theme-default.svg assets/theme-dark.svg diff --git a/snow_first_setup/views/core_progress.py b/snow_first_setup/views/core_progress.py index 31a0989..5746ae9 100644 --- a/snow_first_setup/views/core_progress.py +++ b/snow_first_setup/views/core_progress.py @@ -102,16 +102,16 @@ def __on_items_changed(self, id: str, uid: str, state: backend.ProgressState, in self.actions[uid]["suffix"] = status_suffix def __add_new_action(self, id: str, uid: str, info: dict): - # Only add flatpak installations to this progress view - if id != "install_flatpak": + # Only add flatpak installations and sysext features to this progress view + if id == "install_flatpak": + icon = Gtk.Image.new_from_icon_name(info["app_id"]) + applications.set_app_icon_from_id_async(icon, info["app_id"]) + title = _("Installing") + " " + info["app_name"] + elif id == "enable_sysext": + icon = Gtk.Image.new_from_icon_name("org.gnome.Software-symbolic") + title = _("Enabling") + " " + info["feature_description"] + else: return - - title = "" - icon = None - - icon = Gtk.Image.new_from_icon_name(info["app_id"]) - applications.set_app_icon_from_id_async(icon, info["app_id"]) - title = _("Installing") + " " + info["app_name"] row = Adw.ActionRow() row.set_title(title) diff --git a/snow_first_setup/views/meson.build b/snow_first_setup/views/meson.build index 46b7d96..eb4a532 100644 --- a/snow_first_setup/views/meson.build +++ b/snow_first_setup/views/meson.build @@ -18,6 +18,7 @@ sources = [ 'logout.py', 'progress.py', 'recovery_key.py', + 'sysext.py', 'theme.py', 'timezone.py', 'user.py', diff --git a/snow_first_setup/views/sysext.py b/snow_first_setup/views/sysext.py new file mode 100644 index 0000000..bbb666c --- /dev/null +++ b/snow_first_setup/views/sysext.py @@ -0,0 +1,94 @@ +# sysext.py +# +# 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 Foundationat version 3 of the License. +# +# 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, see . + +from gi.repository import Gtk, Adw + +_ = __builtins__["_"] + +import snow_first_setup.core.backend as backend + +# Features surfaced prominently with their switch on by default; these match +# what the retired "loaded" image variants used to ship preinstalled. +RECOMMENDED_FEATURES = ["edge", "vscode", "bitwarden"] + +@Gtk.Template(resource_path="/org/frostyard/FirstSetup/gtk/sysext.ui") +class VanillaSysext(Adw.Bin): + __gtype_name__ = "VanillaSysext" + + recommended_group = Gtk.Template.Child() + more_group = Gtk.Template.Child() + more_row = Gtk.Template.Child() + + def __init__(self, window, **kwargs): + super().__init__(**kwargs) + self.__window = window + + self.__features = backend.list_sysext_features() + self.__switches = {} + + self.__build_rows() + + @property + def has_features(self) -> bool: + return len(self.__features) > 0 + + def set_page_active(self): + self.__window.set_ready(True) + self.__window.set_focus_on_next() + + def set_page_inactive(self): + return + + def finish(self): + backend.clear_sysext_deferred() + for feature in self.__features: + if self.__switches[feature["name"]].get_active(): + backend.enable_sysext_deferred( + feature["name"], feature["description"] + ) + return True + + def __build_rows(self): + has_recommended = False + has_more = False + + for feature in self.__features: + recommended = feature["name"] in RECOMMENDED_FEATURES + row = self.__build_feature_row(feature, active=recommended or feature["enabled"]) + if recommended: + self.recommended_group.add(row) + has_recommended = True + else: + self.more_row.add_row(row) + has_more = True + + self.recommended_group.set_visible(has_recommended) + self.more_group.set_visible(has_more) + + def __build_feature_row(self, feature: dict, active: bool): + row = Adw.ActionRow( + title=feature["description"], + subtitle=feature["name"], + ) + + switch = Gtk.Switch() + switch.set_active(active) + switch.set_valign(Gtk.Align.CENTER) + switch.set_focusable(False) + + row.add_suffix(switch) + row.set_activatable_widget(switch) + + self.__switches[feature["name"]] = switch + return row diff --git a/snow_first_setup/window.py b/snow_first_setup/window.py index 74107d9..05973eb 100644 --- a/snow_first_setup/window.py +++ b/snow_first_setup/window.py @@ -106,6 +106,7 @@ def __build_ui(self, configure_system_mode: bool, install_mode: bool): from snow_first_setup.views.conn_check import VanillaConnCheck from snow_first_setup.views.hostname import VanillaHostname from snow_first_setup.views.user import VanillaUser + from snow_first_setup.views.sysext import VanillaSysext from snow_first_setup.views.core_progress import VanillaCoreProgress from snow_first_setup.views.logout import VanillaLogout @@ -134,6 +135,7 @@ def __build_ui(self, configure_system_mode: bool, install_mode: bool): # The hostname page was the first interactive page; without # it the user page must not offer "back" into the checks. self.__view_user.no_back_button = True + self.__view_sysext = VanillaSysext(self) self.__view_coreprogress = VanillaCoreProgress(self) self.__view_coreprogress.no_back_button = True self.__view_logout = VanillaLogout(self) @@ -148,6 +150,10 @@ def __build_ui(self, configure_system_mode: bool, install_mode: bool): if ask_hostname: self.pages.append(self.__view_hostname) self.pages.append(self.__view_user) + # Only offer the extensions page when the image ships sysext + # feature definitions to choose from. + if self.__view_sysext.has_features: + self.pages.append(self.__view_sysext) self.pages.append(self.__view_coreprogress) self.pages.append(self.__view_logout)