From a319950e991eb768da24850f4b2620ff570a09eb Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 7 Jul 2026 14:52:10 -0400 Subject: [PATCH] feat: add sysext feature selection page to first-boot setup The loaded image variants are retired (frostyard/snosi#379): Edge, VS Code, Bitwarden, Docker, Incus and friends now ship exclusively as systemd sysexts, all disabled by default. Add a page to the configure-system flow that lets the user pick which sysext features to enable, then enables and downloads them via updex before the first GNOME login (so desktop apps land in the app grid without a re-login). - Discover features by parsing .feature files (plus .feature.d drop-in overrides, matching updex/systemd precedence) from the sysupdate.d directories - Recommended set (edge, vscode, bitwarden) shown on top and enabled by default; the full list sits in a collapsed expander, off by default - Each selected feature is queued as a deferred action running `updex --silent features enable --now` via pkexec, with a per-feature progress row on the existing core-progress page - Skip the page entirely when the image ships no feature definitions Closes #27 Co-Authored-By: Claude Fable 5 --- data/org.frostyard.FirstSetup.policy | 11 ++ po/POTFILES | 2 + snow_first_setup/core/backend.py | 105 ++++++++++++++++++ snow_first_setup/gtk/sysext.ui | 52 +++++++++ snow_first_setup/scripts/meson.build | 1 + snow_first_setup/scripts/sysext-features | 21 ++++ .../snow-first-setup.gresource.xml | 1 + snow_first_setup/views/core_progress.py | 18 +-- snow_first_setup/views/meson.build | 1 + snow_first_setup/views/sysext.py | 94 ++++++++++++++++ snow_first_setup/window.py | 6 + 11 files changed, 303 insertions(+), 9 deletions(-) create mode 100644 snow_first_setup/gtk/sysext.ui create mode 100755 snow_first_setup/scripts/sysext-features create mode 100644 snow_first_setup/views/sysext.py 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)