Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions data/org.frostyard.FirstSetup.policy
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@
<annotate key="org.freedesktop.policykit.exec.path">/usr/share/org.frostyard.FirstSetup/snow_first_setup/scripts/recovery-key</annotate>
</action>

<action id="org.frostyard.FirstSetup.scripts.sysextFeatures">
<description>FirstSetup sysext feature enablement script</description>
<message>Enable and download optional system extensions</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/share/org.frostyard.FirstSetup/snow_first_setup/scripts/sysext-features</annotate>
</action>

<action id="org.frostyard.FirstSetup.scripts.flatpakSystem">
<description>FirstSetup system flatpak installation script</description>
<message>Install system flatpak applications</message>
Expand Down
2 changes: 2 additions & 0 deletions po/POTFILES
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
105 changes: 105 additions & 0 deletions snow_first_setup/core/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>.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])

Expand Down Expand Up @@ -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)
Expand All @@ -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():
Expand Down
52 changes: 52 additions & 0 deletions snow_first_setup/gtk/sysext.ui
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0" />
<template class="VanillaSysext" parent="AdwBin">
<property name="hexpand">1</property>
<property name="vexpand">1</property>
<child>
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="propagate-natural-height">true</property>
<child>
<object class="AdwStatusPage">
<property name="halign">fill</property>
<property name="valign">fill</property>
<property name="hexpand">true</property>
<property name="title" translatable="yes">Extensions</property>
<property name="description" translatable="yes">Choose which system extensions to enable. They will be downloaded during setup.</property>
<property name="icon-name">org.gnome.Software-symbolic</property>
<child>
<object class="AdwClamp">
<property name="maximum_size">500</property>
<property name="tightening_threshold">400</property>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">24</property>
<child>
<object class="AdwPreferencesGroup" id="recommended_group">
<property name="title" translatable="yes">Recommended</property>
</object>
</child>
<child>
<object class="AdwPreferencesGroup" id="more_group">
<child>
<object class="AdwExpanderRow" id="more_row">
<property name="title" translatable="yes">More Extensions</property>
<property name="subtitle" translatable="yes">Developer tools, services and more</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</template>
</interface>
1 change: 1 addition & 0 deletions snow_first_setup/scripts/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ sources = [
'enroll-key',
'recovery-key',
'oemcomplete',
'sysext-features',
]

install_data(sources, install_dir: scriptsdir)
21 changes: 21 additions & 0 deletions snow_first_setup/scripts/sysext-features
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "usage:"
echo "sysext-features <feature name>"
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
1 change: 1 addition & 0 deletions snow_first_setup/snow-first-setup.gresource.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

<file>gtk/layout-applications.ui</file>
<file>gtk/applications-dialog.ui</file>
<file>gtk/sysext.ui</file>

<file>assets/theme-default.svg</file>
<file>assets/theme-dark.svg</file>
Expand Down
18 changes: 9 additions & 9 deletions snow_first_setup/views/core_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions snow_first_setup/views/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ sources = [
'logout.py',
'progress.py',
'recovery_key.py',
'sysext.py',
'theme.py',
'timezone.py',
'user.py',
Expand Down
94 changes: 94 additions & 0 deletions snow_first_setup/views/sysext.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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
Loading
Loading