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 @@
+
+
+
+
+
+ 1
+ 1
+
+
+
+
+
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)