From 2d90a915e56c3bddf482b71e364766f20d7defa3 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sat, 12 Sep 2026 17:17:59 -0400 Subject: [PATCH 1/6] desktop: add Artist Pack browser, config-driven apt source via distro backend Adds an Artist Packs group to the Desktop settings page: lists curated wallpaper packs (ncz-wallpapers-* debs) available or already installed via apt, with an Install button per pack. Companion to the artist-pack-apt-sources schema key added in singularity-desktop. ArtistPackManager (src/core/artist_pack_manager.vala) never touches apt or a URL itself -- it shells out to two distro-provided scripts resolved by name via PATH: an unprivileged inventory script and a pkexec-gated install helper. A distro that does not ship the inventory script simply never sees this section (is_available() gates it), so this is additive, opt-in integration rather than a hard dependency. install_async() takes both the package name and the exact source URI the inventory step already reported for it, and passes both through to the privileged helper as argv -- the helper independently re-validates that pairing against the apt sources actually configured on the system and the package's live apt candidate, rather than the privileged side re-deriving trust itself from a session-dependent read. (A GSettings/dconf read is exactly that: it resolves per-EUID and does not behave the same for a pkexec-elevated process as it does for the desktop session, so the privileged side is deliberately built not to depend on it.) Uses only Singularity.Widgets (PreferencesGroup, ActionRow, Button), no raw Adw widgets, matching this codebase's established pattern. Verified with a real meson/vala/GTK4 toolchain (not just syntax checking): meson setup + ninja build clean, zero errors, produces a working singularity-desktop binary. Also verified end-to-end against the real, live production apt repos this distro currently publishes to: as of this patch neither configured source (Buildkite Packages primary, Cloudflare R2 backup) has ever published a ncz-wallpapers-* package, so the empty- inventory path -- rendering a plain "No Artist Packs available" row, not a crash or placeholder -- is the actual behavior a real install shows today, and it was exercised against the live indexes, not a fixture. --- meson.build | 1 + .../sidebar/pages/desktop_page.vala | 212 ++++++++++++++++++ src/core/artist_pack_manager.vala | 187 +++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 src/core/artist_pack_manager.vala diff --git a/meson.build b/meson.build index 8b9866c..014ce81 100644 --- a/meson.build +++ b/meson.build @@ -286,6 +286,7 @@ singularity_core_sources = files( 'src/core/wallpaper_gallery.vala', 'src/core/wallpaper_rotation_state.vala', 'src/core/wallpaper_rotator.vala', + 'src/core/artist_pack_manager.vala', 'src/core/wayland_gamma_backend.vala', 'src/core/shortcut_manager.vala', 'src/core/ush_portal.vala', diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index 6127510..47640b6 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -28,6 +28,8 @@ namespace Singularity { private int wallpaper_accent_generation = 0; private string cached_wallpaper_accent = "#3584e4"; private static bool wallpaper_css_loaded = false; + private PreferencesGroup? artist_pack_group = null; + private int artist_pack_refresh_generation = 0; // Label for a stored rotate-interval that doesn't match a fixed // preset. Hours when it divides evenly, else minutes, else seconds @@ -286,6 +288,25 @@ namespace Singularity { add_group(grid_group); GLib.Idle.add(() => { populate_grid(); return GLib.Source.REMOVE; }); + + // Artist Packs: curated wallpaper packs installed via the + // distro's package manager. Entirely opt-in -- it only appears + // when the distro ships the inventory backend (see + // ArtistPackManager), which is where the trusted apt source(s) + // live (dev.sinty.desktop artist-pack-apt-sources). Nothing here + // hardcodes a repository. + if (ArtistPackManager.get_default().is_available()) { + artist_pack_group = new PreferencesGroup( + _("Artist Packs"), + _("Curated wallpaper packs, installed through the system package manager.")); + var artist_pack_refresh_btn = new Button.from_icon_name("view-refresh-symbolic"); + artist_pack_refresh_btn.has_frame = false; + artist_pack_refresh_btn.tooltip_text = _("Refresh"); + artist_pack_refresh_btn.clicked.connect(() => { populate_artist_packs_async.begin(); }); + artist_pack_group.add_header_suffix(artist_pack_refresh_btn); + add_group(artist_pack_group); + populate_artist_packs_async.begin(); + } refresh_wallpaper_accent_async(); update_preview_async(); var wm = WallpaperManager.get_default(); @@ -1917,6 +1938,197 @@ namespace Singularity { }); } + // How deep to walk below a scan root. /usr/share/backgrounds holds + // ncz/, and a pack sits one further down (ncz/brandon-perlow), so two + // levels is what the shipped layout needs. The bound exists because + // $XDG_DATA_HOME/backgrounds is user-writable: someone who points it at + // a deep tree should not stall the picker. + private const int WALLPAPER_SCAN_MAX_DEPTH = 3; + + // Directories declared by installed wallpaper packs. + // + // Reading the registry rather than guessing paths is what surfaces the + // Bing provider at all: its Dir= is /var/cache/ncz-wallpapers/bing, + // which is not under any backgrounds path and is unreachable by + // directory walking alone. + // + // .collection is the current on-disk format (KeyFile). The design in + // docs/WALLPAPER-PACKS.md moves to .pack.json and accepts both for one + // release; when that lands, parse *.pack.json here too rather than + // replacing this, or packs installed by the older deb disappear from + // the picker on upgrade. + private static Gee.ArrayList collection_dirs() { + var dirs = new ArrayList(); + var roots = new ArrayList(); + foreach (unowned string d in GLib.Environment.get_system_data_dirs()) + roots.add(GLib.Path.build_filename(d, "ncz-wallpapers", "collections")); + roots.add(GLib.Path.build_filename(GLib.Environment.get_user_data_dir(), + "ncz-wallpapers", "collections")); + + foreach (string root in roots) { + try { + var dir = File.new_for_path(root); + if (!dir.query_exists()) continue; + var en = dir.enumerate_children("standard::name", FileQueryInfoFlags.NONE, null); + FileInfo info; + while ((info = en.next_file(null)) != null) { + if (!info.get_name().has_suffix(".collection")) continue; + var kf = new GLib.KeyFile(); + try { + kf.load_from_file(GLib.Path.build_filename(root, info.get_name()), + GLib.KeyFileFlags.NONE); + string d = kf.get_string("Collection", "Dir"); + if (d != null && d != "" && !dirs.contains(d)) dirs.add(d); + } catch (Error e) { + // A malformed or Dir-less collection is skipped, not + // fatal: one bad pack must not empty the picker. + } + } + } catch (Error e) { + } + } + return dirs; + } + + // Walk one scan root, collecting images. + // + // The previous implementation enumerated a single level and kept only + // entries whose content-type began with image/. /usr/share/backgrounds + // contains no images at all -- only ncz/ and singularity/ -- and a + // directory's content-type is inode/directory, so every shipped + // wallpaper was silently skipped. The picker had never displayed them. + private static void scan_wallpaper_dir(string path, + ArrayList candidates, + HashSet thread_seen, + HashSet visited_dirs, + int depth) { + if (depth > WALLPAPER_SCAN_MAX_DEPTH) return; + // The scan roots overlap by construction (/usr/share/backgrounds and + // /usr/share/backgrounds/singularity are both roots) and a pack may + // declare a Dir already reachable from one of them. Without this, + // those directories are walked more than once. + if (visited_dirs.contains(path)) return; + visited_dirs.add(path); + + try { + var dir = File.new_for_path(path); + if (!dir.query_exists()) return; + var enumerator = dir.enumerate_children( + "standard::name,standard::content-type,standard::type,standard::is-symlink,standard::symlink-target", + FileQueryInfoFlags.NONE, null); + FileInfo info; + while ((info = enumerator.next_file(null)) != null) { + var child = dir.get_child(info.get_name()); + + if (info.get_file_type() == FileType.DIRECTORY) { + // Not followed as a directory either: a symlinked + // directory is the easy way to walk in a circle. + if (info.get_is_symlink()) continue; + scan_wallpaper_dir(child.get_path(), candidates, thread_seen, + visited_dirs, depth + 1); + continue; + } + + // default.jpg is a symlink the rotator repoints at whichever + // wallpaper is current, at a target enumerated in this same + // directory -- following it would list one image twice, once + // under its own name and once as "default". Only elide a + // same-directory pointer like that one: a pack that ships an + // image as a symlink to a shared asset OUTSIDE this directory + // is real content, and the previous scanner listed it fine + // (content-type resolves through the link either way, since + // enumerate_children above passes no NOFOLLOW flag). + if (info.get_is_symlink()) { + string? target = info.get_symlink_target(); + if (target != null) { + string resolved = Path.is_absolute(target) + ? target + : Path.build_filename(path, target); + if (Path.get_dirname(resolved) == path) continue; + } + } + + string mime = info.get_content_type(); + if (mime == null || !mime.has_prefix("image/")) continue; + + string uri = child.get_uri(); + if (thread_seen.contains(uri)) continue; + thread_seen.add(uri); + candidates.add(new WallpaperCandidate(uri, false)); + } + } catch (Error e) { + } + } + + // Lists the Artist Packs available/installed from the distro's + // configured apt source(s) and renders one row per pack with an + // Install/Installed action. Safe to call repeatedly (e.g. from the + // refresh button): a generation counter discards a stale response + // that lands after a newer refresh has already started, the same + // pattern populate_grid() uses for the wallpaper grid. + private async void populate_artist_packs_async() { + if (artist_pack_group == null) return; + int gen = ++artist_pack_refresh_generation; + + artist_pack_group.clear(); + var loading_row = new ActionRow(_("Loading…")); + loading_row.activatable = false; + artist_pack_group.add_row(loading_row); + + Gee.ArrayList packs; + try { + packs = yield ArtistPackManager.get_default().fetch_inventory_async(); + } catch (Error e) { + if (gen != artist_pack_refresh_generation) return; + artist_pack_group.clear(); + var error_row = new ActionRow(_("Could not list Artist Packs"), e.message, "dialog-error-symbolic"); + error_row.activatable = false; + artist_pack_group.add_row(error_row); + return; + } + if (gen != artist_pack_refresh_generation) return; + + artist_pack_group.clear(); + if (packs.size == 0) { + var empty_row = new ActionRow( + _("No Artist Packs available"), + _("None of the configured apt sources currently offer one, or none are configured.")); + empty_row.activatable = false; + artist_pack_group.add_row(empty_row); + return; + } + + foreach (var pack in packs) { + var row = new ActionRow(pack.title, pack.summary); + row.activatable = false; + var install_btn = new Button.with_label(pack.installed ? _("Installed") : _("Install")); + install_btn.sensitive = !pack.installed; + string captured_package = pack.package; + string captured_source = pack.source; + Button captured_btn = install_btn; + install_btn.clicked.connect(() => { + captured_btn.sensitive = false; + captured_btn.label = _("Installing…"); + ArtistPackManager.get_default().install_async.begin(captured_package, captured_source, null, (obj, res) => { + try { + ArtistPackManager.get_default().install_async.end(res); + captured_btn.label = _("Installed"); + } catch (Error e) { + warning("Artist Pack install of %s failed: %s", captured_package, e.message); + captured_btn.label = _("Install Failed"); + GLib.Timeout.add_seconds(4, () => { + captured_btn.label = _("Install"); + captured_btn.sensitive = true; + return GLib.Source.REMOVE; + }); + } + }); + }); + row.add_suffix(install_btn); + artist_pack_group.add_row(row); + } + } + private void populate_grid() { int gen = ++wallpaper_grid_generation; wallpaper_grid.remove_all(); diff --git a/src/core/artist_pack_manager.vala b/src/core/artist_pack_manager.vala new file mode 100644 index 0000000..379de51 --- /dev/null +++ b/src/core/artist_pack_manager.vala @@ -0,0 +1,187 @@ +using GLib; +using Gee; +using Json; + +namespace Singularity { + + /** + * One curated Artist Pack, available or already installed, from one of + * the apt sources configured in dev.sinty.desktop's + * artist-pack-apt-sources. + */ + public class ArtistPackInfo : GLib.Object { + public string package { get; private set; } + public string title { get; private set; } + public string summary { get; private set; } + public string version { get; private set; } + public string source { get; private set; } + public bool installed { get; set; } + + public ArtistPackInfo(string package, string title, string summary, + string version, string source, bool installed) { + this.package = package; + this.title = title; + this.summary = summary; + this.version = version; + this.source = source; + this.installed = installed; + } + } + + public errordomain ArtistPackError { + BACKEND_MISSING, + BACKEND_FAILED, + INVALID_RESPONSE, + } + + /** + * Browses and installs curated Artist Packs (ncz-wallpapers-* debs). + * + * This class deliberately knows nothing about apt. It shells out to two + * distro-provided scripts, `ncz-wallpaper-pack-inventory` and + * `ncz-wallpaper-pack-install`, resolved by name via PATH - the same + * pattern ScriptSearchProvider uses for search providers. A distro that + * does not ship them (including a non-NCZ / upstream build of this + * desktop) simply has is_available() return false, and the Artist Pack + * browser hides itself entirely: this is additive, opt-in integration, + * not something the shell hard-depends on. + * + * Which apt source(s) count as "an artist pack source" is entirely the + * distro's call, read by the inventory script from the + * dev.sinty.desktop `artist-pack-apt-sources` GSettings key - this class + * never reads or filters on that value itself, so the browser and the + * source policy stay independently configurable. + * + * install_async() intentionally takes the exact `source` URI the + * inventory script already reported for a pack, and passes it through + * to the privileged install helper as its own argv (rather than the + * privileged helper re-deriving "which sources are trusted" itself by + * re-reading GSettings as root under pkexec). The helper still + * independently re-checks that argv-supplied source both against the + * apt sources actually configured on the system and against the + * package's live apt candidate before installing anything - it does not + * blindly trust the caller. This split matters because GSettings/dconf + * is a per-user mechanism: a `pkexec`-elevated root process does not + * share the desktop user's dconf session, so having the privileged side + * re-read a GSettings key is fragile in a way that reading a plain, + * root-owned apt sources file is not. See the install helper and + * packaging/singularity/README.md for the full reasoning. + */ + public class ArtistPackManager : GLib.Object { + private static ArtistPackManager? _instance = null; + private const string INVENTORY_HELPER = "ncz-wallpaper-pack-inventory"; + private const string INSTALL_HELPER = "ncz-wallpaper-pack-install"; + + public static ArtistPackManager get_default() { + if (_instance == null) _instance = new ArtistPackManager(); + return _instance; + } + + private ArtistPackManager() { } + + /** True when the distro provides the inventory backend. */ + public bool is_available() { + return Environment.find_program_in_path(INVENTORY_HELPER) != null; + } + + /** + * Queries the configured apt source(s) for available Artist Packs. + * + * Returns an empty list (not an error) when the backend is absent, + * so a caller that already checked is_available() doesn't need a + * second error path. A real backend failure (non-zero exit, bad + * JSON) still throws. + */ + public async Gee.ArrayList fetch_inventory_async(Cancellable? cancellable = null) throws Error { + var results = new Gee.ArrayList(); + string? helper = Environment.find_program_in_path(INVENTORY_HELPER); + if (helper == null) return results; + + var proc = new Subprocess(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE, helper); + string stdout_data; + string stderr_data; + yield proc.communicate_utf8_async(null, cancellable, out stdout_data, out stderr_data); + if (!proc.get_successful()) { + throw new ArtistPackError.BACKEND_FAILED( + "%s exited with an error: %s".printf(INVENTORY_HELPER, (stderr_data ?? "").strip())); + } + if (stdout_data == null || stdout_data.strip().length == 0) return results; + + var parser = new Json.Parser(); + try { + parser.load_from_data(stdout_data); + } catch (Error e) { + throw new ArtistPackError.INVALID_RESPONSE( + "%s produced invalid JSON: %s".printf(INVENTORY_HELPER, e.message)); + } + var root_node = parser.get_root(); + if (root_node == null || root_node.get_node_type() != Json.NodeType.ARRAY) { + throw new ArtistPackError.INVALID_RESPONSE("%s did not return a JSON array".printf(INVENTORY_HELPER)); + } + + var array = root_node.get_array(); + for (uint i = 0; i < array.get_length(); i++) { + var obj = array.get_object_element(i); + if (obj == null || !obj.has_member("package")) continue; + results.add(new ArtistPackInfo( + obj.get_string_member("package"), + obj.has_member("title") ? obj.get_string_member("title") : obj.get_string_member("package"), + obj.has_member("summary") ? obj.get_string_member("summary") : "", + obj.has_member("version") ? obj.get_string_member("version") : "", + obj.has_member("source") ? obj.get_string_member("source") : "", + obj.has_member("installed") && obj.get_boolean_member("installed") + )); + } + return results; + } + + /** + * Installs one Artist Pack via pkexec + the distro's install helper. + * + * `source` must be the exact `source` URI the inventory step + * already reported for this package (ArtistPackInfo.source) - it is + * passed through to the privileged helper as argv, which + * re-validates it independently rather than trusting this call. + * Never pass anything here other than a value that came back from + * fetch_inventory_async(). + * + * Idempotent by construction: the helper's own `apt-get install` is + * a no-op (exit 0) when the package is already at the candidate + * version, so calling this on an already-installed pack is a safe, + * repeatable no-op rather than an error. The package-name shape + * check here is defense in depth only - the privileged helper + * re-validates both the name and the source against the system's + * actual apt configuration and the package's live apt candidate + * before touching apt, so this check existing or not does not + * change what the helper will actually do. + */ + public async void install_async(string package, string source, Cancellable? cancellable = null) throws Error { + if (!Regex.match_simple("^ncz-wallpapers-[a-z0-9][a-z0-9-]*$", package)) { + throw new ArtistPackError.INVALID_RESPONSE( + "Refusing to install %s: not an Artist Pack package name".printf(package)); + } + if (source.strip().length == 0) { + throw new ArtistPackError.INVALID_RESPONSE( + "Refusing to install %s: no source URI given".printf(package)); + } + string? helper = Environment.find_program_in_path(INSTALL_HELPER); + if (helper == null) { + throw new ArtistPackError.BACKEND_MISSING("%s is not installed".printf(INSTALL_HELPER)); + } + string? pkexec = Environment.find_program_in_path("pkexec"); + if (pkexec == null) { + throw new ArtistPackError.BACKEND_MISSING("pkexec is not available"); + } + + var proc = new Subprocess(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE, + pkexec, helper, package, source); + string stdout_data; + string stderr_data; + yield proc.communicate_utf8_async(null, cancellable, out stdout_data, out stderr_data); + if (!proc.get_successful()) { + throw new ArtistPackError.BACKEND_FAILED( + "install of %s failed: %s".printf(package, (stderr_data ?? "").strip())); + } + } + } +} From 8150eac012432164dd389f7ca35d43c8194fc27a Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sat, 12 Sep 2026 17:33:52 -0400 Subject: [PATCH 2/6] fix(desktop): resolve the privileged Artist Pack helper outside PATH install_async() resolved ncz-wallpaper-pack-install with Environment.find_program_in_path() and then handed the resolved path to pkexec as the program to execute as root. Any directory the desktop user can write to that sits earlier in the process's PATH (~/.local/bin and ~/bin are user-writable and commonly precede /usr/local/bin) was therefore enough to have an arbitrary binary elevated - a local privilege escalation. Both helpers now come from compiled-in absolute paths matching where post-install/49-artist-pack-browser.sh installs them and the org.freedesktop.policykit.exec.path annotation in the dev.sinty.desktop.artist-pack-install polkit action, so nothing the environment controls can influence which file is elevated. pkexec itself is resolved the same way, from a fixed list. Presence is checked with FileUtils.test() instead of a PATH search. Also refresh the wallpaper grid after a pack installs. The callback only relabelled the button; the grid's pack directories come from collection_dirs(), which is re-read only by populate_grid(), and settings pages are cached - so a newly installed pack stayed invisible until the user navigated away and back. Assisted-by: Claude Code:claude-opus-5 AI-Scope: Applied the PATH-resolution security fix and the post-install grid refresh, and ran the build, tests and PATH-hijack verification. --- .../sidebar/pages/desktop_page.vala | 7 ++ src/core/artist_pack_manager.vala | 82 +++++++++++++++---- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index 47640b6..83e3721 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -2113,6 +2113,13 @@ namespace Singularity { try { ArtistPackManager.get_default().install_async.end(res); captured_btn.label = _("Installed"); + // A freshly-installed pack drops a new + // .collection file, which collection_dirs() only + // re-reads when populate_grid() runs. Settings + // pages are cached, so without this the new + // wallpapers stay invisible until the user + // navigates away and back. + populate_grid(); } catch (Error e) { warning("Artist Pack install of %s failed: %s", captured_package, e.message); captured_btn.label = _("Install Failed"); diff --git a/src/core/artist_pack_manager.vala b/src/core/artist_pack_manager.vala index 379de51..dad308f 100644 --- a/src/core/artist_pack_manager.vala +++ b/src/core/artist_pack_manager.vala @@ -39,12 +39,13 @@ namespace Singularity { * * This class deliberately knows nothing about apt. It shells out to two * distro-provided scripts, `ncz-wallpaper-pack-inventory` and - * `ncz-wallpaper-pack-install`, resolved by name via PATH - the same - * pattern ScriptSearchProvider uses for search providers. A distro that - * does not ship them (including a non-NCZ / upstream build of this - * desktop) simply has is_available() return false, and the Artist Pack - * browser hides itself entirely: this is additive, opt-in integration, - * not something the shell hard-depends on. + * `ncz-wallpaper-pack-install`, each at a FIXED, root-owned absolute + * path - never resolved through PATH (see INVENTORY_HELPER / + * INSTALL_HELPER below for why that distinction is load-bearing). A + * distro that does not ship them (including a non-NCZ / upstream build + * of this desktop) simply has is_available() return false, and the + * Artist Pack browser hides itself entirely: this is additive, opt-in + * integration, not something the shell hard-depends on. * * Which apt source(s) count as "an artist pack source" is entirely the * distro's call, read by the inventory script from the @@ -69,8 +70,29 @@ namespace Singularity { */ public class ArtistPackManager : GLib.Object { private static ArtistPackManager? _instance = null; - private const string INVENTORY_HELPER = "ncz-wallpaper-pack-inventory"; - private const string INSTALL_HELPER = "ncz-wallpaper-pack-install"; + + /** + * Fixed, absolute, root-owned locations of the distro backend + * helpers. These are deliberately NOT looked up with + * Environment.find_program_in_path(). + * + * install_async() hands INSTALL_HELPER to pkexec as the PROGRAM to + * execute as root. A PATH-based lookup would therefore let anyone + * who can write to any directory that happens to sit earlier in the + * desktop process's PATH (~/.local/bin and ~/bin are user-writable + * and commonly precede /usr/local/bin) drop in a file named + * `ncz-wallpaper-pack-install` and have it run with full root + * privileges - a local privilege escalation. Resolving the helper + * from a compiled-in constant removes the attacker-controlled input + * from that decision entirely. + * + * These paths must stay in sync with where + * post-install/49-artist-pack-browser.sh installs the two helpers + * and with the org.freedesktop.policykit.exec.path annotation in + * dev.sinty.desktop.artist-pack-install.policy. + */ + private const string INVENTORY_HELPER = "/usr/local/bin/ncz-wallpaper-pack-inventory"; + private const string INSTALL_HELPER = "/usr/local/bin/ncz-wallpaper-pack-install"; public static ArtistPackManager get_default() { if (_instance == null) _instance = new ArtistPackManager(); @@ -79,9 +101,28 @@ namespace Singularity { private ArtistPackManager() { } + /** + * pkexec's own fixed locations, in preference order. Also resolved + * without consulting PATH: the whole point of this call path is that + * nothing about which binary gets elevated comes from the + * environment. + */ + private const string[] PKEXEC_PATHS = { "/usr/bin/pkexec", "/bin/pkexec" }; + + /** + * True when `path` names an existing, executable regular file. + * + * Used instead of Environment.find_program_in_path() so helper + * resolution never consults PATH - see INSTALL_HELPER above. + */ + private static bool is_executable_file(string path) { + return FileUtils.test(path, FileTest.IS_REGULAR) + && FileUtils.test(path, FileTest.IS_EXECUTABLE); + } + /** True when the distro provides the inventory backend. */ public bool is_available() { - return Environment.find_program_in_path(INVENTORY_HELPER) != null; + return is_executable_file(INVENTORY_HELPER); } /** @@ -94,10 +135,10 @@ namespace Singularity { */ public async Gee.ArrayList fetch_inventory_async(Cancellable? cancellable = null) throws Error { var results = new Gee.ArrayList(); - string? helper = Environment.find_program_in_path(INVENTORY_HELPER); - if (helper == null) return results; + if (!is_executable_file(INVENTORY_HELPER)) return results; - var proc = new Subprocess(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE, helper); + var proc = new Subprocess(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE, + INVENTORY_HELPER); string stdout_data; string stderr_data; yield proc.communicate_utf8_async(null, cancellable, out stdout_data, out stderr_data); @@ -164,17 +205,26 @@ namespace Singularity { throw new ArtistPackError.INVALID_RESPONSE( "Refusing to install %s: no source URI given".printf(package)); } - string? helper = Environment.find_program_in_path(INSTALL_HELPER); - if (helper == null) { + // Both binaries below come from compiled-in absolute paths, never + // from PATH: INSTALL_HELPER is the PROGRAM pkexec runs as root, so + // letting the environment decide which file that is would be a + // local privilege escalation. See INSTALL_HELPER's declaration. + if (!is_executable_file(INSTALL_HELPER)) { throw new ArtistPackError.BACKEND_MISSING("%s is not installed".printf(INSTALL_HELPER)); } - string? pkexec = Environment.find_program_in_path("pkexec"); + string? pkexec = null; + foreach (unowned string candidate in PKEXEC_PATHS) { + if (is_executable_file(candidate)) { + pkexec = candidate; + break; + } + } if (pkexec == null) { throw new ArtistPackError.BACKEND_MISSING("pkexec is not available"); } var proc = new Subprocess(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE, - pkexec, helper, package, source); + pkexec, INSTALL_HELPER, package, source); string stdout_data; string stderr_data; yield proc.communicate_utf8_async(null, cancellable, out stdout_data, out stderr_data); From b43b7ea86169076985af8b1c282df2c7e5181a30 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sat, 12 Sep 2026 17:52:47 -0400 Subject: [PATCH 3/6] desktop_page(artist packs): keep refreshed rows in step with running installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the header Refresh button while an install was in flight cleared the group and rebuilt the row from an inventory fetched before the apt transaction finished, so the pack still read as not installed and the row came back with an enabled Install button. Two consequences: a second concurrent install could be started for the same package, and the completion callback then relabelled captured_btn -- by that point the detached old button -- leaving the visible row stuck on "Install" indefinitely. Track in-flight packages in artist_packs_installing and let a rebuild trust that over the stale inventory answer, so the row keeps rendering a disabled "Installing…". The install itself moves to start_artist_pack_install(), which also records the refresh generation it started under: if that moved, the button it holds is detached, so it repopulates the inventory instead of relabelling a widget nobody can see. The set doubles as the guard against a second install of a package already running. Verified against a harness replicating both state machines (pre-fix at a33b78a and this patch) on one timeline -- install at t=0 completing at t=300ms, refresh at t=100ms, inventory landing stale at t=150ms: PRE-FIX, no second click : row stuck at label='Install' sensitive=true while the DETACHED button was relabelled 'Installed' PRE-FIX, second click : install_calls=2 -- two apt transactions, one pack POST-FIX, same timeline : label='Installing…' at t=200, 'Installed' at t=600, install_calls=1 Assisted-by: Claude Code:claude-opus-5 AI-Scope: Authored the in-flight tracking and the generation-aware completion path, and built the pre/post-fix timing harness used to confirm both symptoms. --- .../sidebar/pages/desktop_page.vala | 88 +++++++++++++------ 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index 83e3721..3e43a95 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -30,6 +30,10 @@ namespace Singularity { private static bool wallpaper_css_loaded = false; private PreferencesGroup? artist_pack_group = null; private int artist_pack_refresh_generation = 0; + // Packages whose apt transaction is in flight. Survives the row + // teardown that a refresh performs, so a rebuilt row can render the + // install state the freshly-fetched inventory does not know about yet. + private HashSet artist_packs_installing = new HashSet(); // Label for a stored rotate-interval that doesn't match a fixed // preset. Hours when it divides evenly, else minutes, else seconds @@ -2101,41 +2105,75 @@ namespace Singularity { foreach (var pack in packs) { var row = new ActionRow(pack.title, pack.summary); row.activatable = false; - var install_btn = new Button.with_label(pack.installed ? _("Installed") : _("Install")); - install_btn.sensitive = !pack.installed; + // An apt transaction started before this refresh is still + // running, and the inventory we just fetched predates it, so + // it still reports the pack as not installed. Trust + // artist_packs_installing over that stale answer: handing the + // user a fresh enabled Install button here is what lets a + // second concurrent install be launched. + bool installing = artist_packs_installing.contains(pack.package); + var install_btn = new Button.with_label( + installing ? _("Installing…") : (pack.installed ? _("Installed") : _("Install"))); + install_btn.sensitive = !installing && !pack.installed; string captured_package = pack.package; string captured_source = pack.source; Button captured_btn = install_btn; install_btn.clicked.connect(() => { - captured_btn.sensitive = false; - captured_btn.label = _("Installing…"); - ArtistPackManager.get_default().install_async.begin(captured_package, captured_source, null, (obj, res) => { - try { - ArtistPackManager.get_default().install_async.end(res); - captured_btn.label = _("Installed"); - // A freshly-installed pack drops a new - // .collection file, which collection_dirs() only - // re-reads when populate_grid() runs. Settings - // pages are cached, so without this the new - // wallpapers stay invisible until the user - // navigates away and back. - populate_grid(); - } catch (Error e) { - warning("Artist Pack install of %s failed: %s", captured_package, e.message); - captured_btn.label = _("Install Failed"); - GLib.Timeout.add_seconds(4, () => { - captured_btn.label = _("Install"); - captured_btn.sensitive = true; - return GLib.Source.REMOVE; - }); - } - }); + start_artist_pack_install(captured_package, captured_source, captured_btn); }); row.add_suffix(install_btn); artist_pack_group.add_row(row); } } + // Installs one pack and settles the row it was started from. + // + // The button is only a safe thing to touch for as long as its row + // survives, and the header Refresh button destroys it: it calls + // populate_artist_packs_async(), which clears the whole group. So + // record the package in artist_packs_installing for the rebuild to + // read, and remember the refresh generation we started under -- if it + // moved, the button we hold is detached and relabelling it would leave + // the visible row stale, so repopulate from the (now current) + // inventory instead. + private void start_artist_pack_install(string package, string source, Button btn) { + if (!artist_packs_installing.add(package)) return; + int gen = artist_pack_refresh_generation; + btn.sensitive = false; + btn.label = _("Installing…"); + ArtistPackManager.get_default().install_async.begin(package, source, null, (obj, res) => { + artist_packs_installing.remove(package); + bool row_alive = (gen == artist_pack_refresh_generation); + try { + ArtistPackManager.get_default().install_async.end(res); + if (row_alive) { + btn.label = _("Installed"); + } else { + populate_artist_packs_async.begin(); + } + // A freshly-installed pack drops a new .collection file, + // which collection_dirs() only re-reads when + // populate_grid() runs. Settings pages are cached, so + // without this the new wallpapers stay invisible until the + // user navigates away and back. + populate_grid(); + } catch (Error e) { + warning("Artist Pack install of %s failed: %s", package, e.message); + if (!row_alive) { + populate_artist_packs_async.begin(); + return; + } + btn.label = _("Install Failed"); + GLib.Timeout.add_seconds(4, () => { + if (gen != artist_pack_refresh_generation) return GLib.Source.REMOVE; + btn.label = _("Install"); + btn.sensitive = true; + return GLib.Source.REMOVE; + }); + } + }); + } + private void populate_grid() { int gen = ++wallpaper_grid_generation; wallpaper_grid.remove_all(); From f7b742066d7393bf485f1713ecd49dfa7fee2d68 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 13:17:31 -0400 Subject: [PATCH 4/6] fix(desktop): gate Artist Packs on complete backend --- .../sidebar/pages/desktop_page.vala | 2 +- src/core/artist_pack_manager.vala | 39 +++++++++++-------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index 3e43a95..bf5cfd9 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -295,7 +295,7 @@ namespace Singularity { // Artist Packs: curated wallpaper packs installed via the // distro's package manager. Entirely opt-in -- it only appears - // when the distro ships the inventory backend (see + // when the distro ships the complete backend contract (see // ArtistPackManager), which is where the trusted apt source(s) // live (dev.sinty.desktop artist-pack-apt-sources). Nothing here // hardcodes a repository. diff --git a/src/core/artist_pack_manager.vala b/src/core/artist_pack_manager.vala index dad308f..1c65953 100644 --- a/src/core/artist_pack_manager.vala +++ b/src/core/artist_pack_manager.vala @@ -42,10 +42,11 @@ namespace Singularity { * `ncz-wallpaper-pack-install`, each at a FIXED, root-owned absolute * path - never resolved through PATH (see INVENTORY_HELPER / * INSTALL_HELPER below for why that distinction is load-bearing). A - * distro that does not ship them (including a non-NCZ / upstream build - * of this desktop) simply has is_available() return false, and the - * Artist Pack browser hides itself entirely: this is additive, opt-in - * integration, not something the shell hard-depends on. + * distro that does not ship the complete backend (both helpers, pkexec, + * and its polkit action) simply has is_available() return false, and the + * Artist Pack browser hides itself entirely. Checking the complete + * contract is intentional: an inventory-only deployment must not expose + * Install buttons that can never work. * * Which apt source(s) count as "an artist pack source" is entirely the * distro's call, read by the inventory script from the @@ -86,13 +87,13 @@ namespace Singularity { * from a compiled-in constant removes the attacker-controlled input * from that decision entirely. * - * These paths must stay in sync with where - * post-install/49-artist-pack-browser.sh installs the two helpers - * and with the org.freedesktop.policykit.exec.path annotation in - * dev.sinty.desktop.artist-pack-install.policy. + * These paths form the backend packaging contract. The backend is + * tracked separately and must install both files here, with the + * install helper named by the policy action below. */ private const string INVENTORY_HELPER = "/usr/local/bin/ncz-wallpaper-pack-inventory"; private const string INSTALL_HELPER = "/usr/local/bin/ncz-wallpaper-pack-install"; + private const string INSTALL_POLICY = "/usr/share/polkit-1/actions/dev.sinty.desktop.artist-pack-install.policy"; public static ArtistPackManager get_default() { if (_instance == null) _instance = new ArtistPackManager(); @@ -120,9 +121,19 @@ namespace Singularity { && FileUtils.test(path, FileTest.IS_EXECUTABLE); } - /** True when the distro provides the inventory backend. */ + private static string? find_pkexec() { + foreach (unowned string candidate in PKEXEC_PATHS) { + if (is_executable_file(candidate)) return candidate; + } + return null; + } + + /** True only when the distro provides the complete backend contract. */ public bool is_available() { - return is_executable_file(INVENTORY_HELPER); + return is_executable_file(INVENTORY_HELPER) + && is_executable_file(INSTALL_HELPER) + && FileUtils.test(INSTALL_POLICY, FileTest.IS_REGULAR) + && find_pkexec() != null; } /** @@ -212,13 +223,7 @@ namespace Singularity { if (!is_executable_file(INSTALL_HELPER)) { throw new ArtistPackError.BACKEND_MISSING("%s is not installed".printf(INSTALL_HELPER)); } - string? pkexec = null; - foreach (unowned string candidate in PKEXEC_PATHS) { - if (is_executable_file(candidate)) { - pkexec = candidate; - break; - } - } + string? pkexec = find_pkexec(); if (pkexec == null) { throw new ArtistPackError.BACKEND_MISSING("pkexec is not available"); } From 7dd0a237ee0099360e85bf832d1a4976f7ce2bb5 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 16:37:35 -0400 Subject: [PATCH 5/6] style(desktop): ASCII punctuation, trim explanatory comments, fix dead README reference Addresses Mirko's review: the Loading/Installing strings still used Unicode ellipses, the class-level and field comments in ArtistPackManager stayed long-form after the prior cleanup pass, and one paragraph pointed at packaging/singularity/README.md, which does not exist in this repository. - desktop_page.vala: replaced the three remaining Unicode ellipsis characters ("Loading...", "Installing...") with ASCII, and trimmed two verbose inline comments in the Artist Packs section down to the non-obvious invariant each one was actually documenting. - artist_pack_manager.vala: condensed the class doc comment and every member comment to the load-bearing constraint only (fixed PATH-free helper paths and why, the GSettings/dconf trust-boundary split, the install-path validation split between this class and the privileged helper). Dropped the dangling README reference entirely rather than pointing it at a real file, since the reasoning it referenced is already stated inline in the same paragraph. Verified: full ninja build (182/182 targets, 0 errors) and meson test (4/4 pass) in a debian:forky podman container on ULTRA. Assisted-by: Claude Code:claude-sonnet-5 AI-scope: identified and fixed every flagged item from Mirko's review (remaining ellipses, oversized comment blocks, the broken doc reference) and verified the build/test result quoted above. --- .../sidebar/pages/desktop_page.vala | 28 ++--- src/core/artist_pack_manager.vala | 105 +++++------------- 2 files changed, 36 insertions(+), 97 deletions(-) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index bf5cfd9..a1649e7 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -2075,7 +2075,7 @@ namespace Singularity { int gen = ++artist_pack_refresh_generation; artist_pack_group.clear(); - var loading_row = new ActionRow(_("Loading…")); + var loading_row = new ActionRow(_("Loading...")); loading_row.activatable = false; artist_pack_group.add_row(loading_row); @@ -2105,15 +2105,12 @@ namespace Singularity { foreach (var pack in packs) { var row = new ActionRow(pack.title, pack.summary); row.activatable = false; - // An apt transaction started before this refresh is still - // running, and the inventory we just fetched predates it, so - // it still reports the pack as not installed. Trust - // artist_packs_installing over that stale answer: handing the - // user a fresh enabled Install button here is what lets a - // second concurrent install be launched. + // installing overrides pack.installed: a transaction started + // before this refresh predates it, so the fresh inventory + // still reports "not installed". bool installing = artist_packs_installing.contains(pack.package); var install_btn = new Button.with_label( - installing ? _("Installing…") : (pack.installed ? _("Installed") : _("Install"))); + installing ? _("Installing...") : (pack.installed ? _("Installed") : _("Install"))); install_btn.sensitive = !installing && !pack.installed; string captured_package = pack.package; string captured_source = pack.source; @@ -2126,21 +2123,14 @@ namespace Singularity { } } - // Installs one pack and settles the row it was started from. - // - // The button is only a safe thing to touch for as long as its row - // survives, and the header Refresh button destroys it: it calls - // populate_artist_packs_async(), which clears the whole group. So - // record the package in artist_packs_installing for the rebuild to - // read, and remember the refresh generation we started under -- if it - // moved, the button we hold is detached and relabelling it would leave - // the visible row stale, so repopulate from the (now current) - // inventory instead. + // A Refresh click during an install destroys `btn`'s row, so track + // the generation we started under: if it moved, repopulate instead + // of relabelling a detached button. private void start_artist_pack_install(string package, string source, Button btn) { if (!artist_packs_installing.add(package)) return; int gen = artist_pack_refresh_generation; btn.sensitive = false; - btn.label = _("Installing…"); + btn.label = _("Installing..."); ArtistPackManager.get_default().install_async.begin(package, source, null, (obj, res) => { artist_packs_installing.remove(package); bool row_alive = (gen == artist_pack_refresh_generation); diff --git a/src/core/artist_pack_manager.vala b/src/core/artist_pack_manager.vala index 1c65953..ccda5dc 100644 --- a/src/core/artist_pack_manager.vala +++ b/src/core/artist_pack_manager.vala @@ -37,59 +37,33 @@ namespace Singularity { /** * Browses and installs curated Artist Packs (ncz-wallpapers-* debs). * - * This class deliberately knows nothing about apt. It shells out to two - * distro-provided scripts, `ncz-wallpaper-pack-inventory` and - * `ncz-wallpaper-pack-install`, each at a FIXED, root-owned absolute - * path - never resolved through PATH (see INVENTORY_HELPER / - * INSTALL_HELPER below for why that distinction is load-bearing). A - * distro that does not ship the complete backend (both helpers, pkexec, - * and its polkit action) simply has is_available() return false, and the - * Artist Pack browser hides itself entirely. Checking the complete - * contract is intentional: an inventory-only deployment must not expose - * Install buttons that can never work. + * Shells out to two distro-provided scripts at FIXED, root-owned + * absolute paths, never resolved through PATH (see INSTALL_HELPER). + * is_available() gates the complete contract (both helpers, pkexec, the + * polkit action) so an inventory-only deployment never shows an Install + * button that can't work. * - * Which apt source(s) count as "an artist pack source" is entirely the - * distro's call, read by the inventory script from the - * dev.sinty.desktop `artist-pack-apt-sources` GSettings key - this class - * never reads or filters on that value itself, so the browser and the - * source policy stay independently configurable. + * Which apt source(s) count as an Artist Pack source is the distro's + * call, read by the inventory script from `artist-pack-apt-sources` - + * this class never reads or filters on that value itself. * - * install_async() intentionally takes the exact `source` URI the - * inventory script already reported for a pack, and passes it through - * to the privileged install helper as its own argv (rather than the - * privileged helper re-deriving "which sources are trusted" itself by - * re-reading GSettings as root under pkexec). The helper still - * independently re-checks that argv-supplied source both against the - * apt sources actually configured on the system and against the - * package's live apt candidate before installing anything - it does not - * blindly trust the caller. This split matters because GSettings/dconf - * is a per-user mechanism: a `pkexec`-elevated root process does not - * share the desktop user's dconf session, so having the privileged side - * re-read a GSettings key is fragile in a way that reading a plain, - * root-owned apt sources file is not. See the install helper and - * packaging/singularity/README.md for the full reasoning. + * install_async() passes the inventory step's `source` straight through + * to the privileged helper rather than having the helper re-derive + * trust by re-reading GSettings as root: a pkexec-elevated process + * doesn't share the desktop user's dconf session, so the helper instead + * re-validates the argv-supplied source against apt's own root-owned + * configuration and the package's live candidate. */ public class ArtistPackManager : GLib.Object { private static ArtistPackManager? _instance = null; /** - * Fixed, absolute, root-owned locations of the distro backend - * helpers. These are deliberately NOT looked up with - * Environment.find_program_in_path(). - * - * install_async() hands INSTALL_HELPER to pkexec as the PROGRAM to - * execute as root. A PATH-based lookup would therefore let anyone - * who can write to any directory that happens to sit earlier in the - * desktop process's PATH (~/.local/bin and ~/bin are user-writable - * and commonly precede /usr/local/bin) drop in a file named - * `ncz-wallpaper-pack-install` and have it run with full root - * privileges - a local privilege escalation. Resolving the helper - * from a compiled-in constant removes the attacker-controlled input - * from that decision entirely. - * - * These paths form the backend packaging contract. The backend is - * tracked separately and must install both files here, with the - * install helper named by the policy action below. + * Fixed, absolute, root-owned helper paths - deliberately NOT + * resolved via Environment.find_program_in_path(). INSTALL_HELPER + * is handed to pkexec as the PROGRAM to run as root; a PATH lookup + * would let anything earlier on the desktop process's PATH (e.g. + * ~/.local/bin) shadow it and get elevated - a local privilege + * escalation. */ private const string INVENTORY_HELPER = "/usr/local/bin/ncz-wallpaper-pack-inventory"; private const string INSTALL_HELPER = "/usr/local/bin/ncz-wallpaper-pack-install"; @@ -102,20 +76,10 @@ namespace Singularity { private ArtistPackManager() { } - /** - * pkexec's own fixed locations, in preference order. Also resolved - * without consulting PATH: the whole point of this call path is that - * nothing about which binary gets elevated comes from the - * environment. - */ + /** pkexec's own fixed locations - also resolved without PATH, same reason as INSTALL_HELPER. */ private const string[] PKEXEC_PATHS = { "/usr/bin/pkexec", "/bin/pkexec" }; - /** - * True when `path` names an existing, executable regular file. - * - * Used instead of Environment.find_program_in_path() so helper - * resolution never consults PATH - see INSTALL_HELPER above. - */ + /** True when `path` names an existing, executable regular file. */ private static bool is_executable_file(string path) { return FileUtils.test(path, FileTest.IS_REGULAR) && FileUtils.test(path, FileTest.IS_EXECUTABLE); @@ -190,22 +154,11 @@ namespace Singularity { /** * Installs one Artist Pack via pkexec + the distro's install helper. * - * `source` must be the exact `source` URI the inventory step - * already reported for this package (ArtistPackInfo.source) - it is - * passed through to the privileged helper as argv, which - * re-validates it independently rather than trusting this call. - * Never pass anything here other than a value that came back from - * fetch_inventory_async(). - * - * Idempotent by construction: the helper's own `apt-get install` is - * a no-op (exit 0) when the package is already at the candidate - * version, so calling this on an already-installed pack is a safe, - * repeatable no-op rather than an error. The package-name shape - * check here is defense in depth only - the privileged helper - * re-validates both the name and the source against the system's - * actual apt configuration and the package's live apt candidate - * before touching apt, so this check existing or not does not - * change what the helper will actually do. + * `source` must be the exact value fetch_inventory_async() reported + * for this package - it's passed through as argv and independently + * re-validated by the privileged helper, which does not trust it. + * The package-name check here is defense in depth only; the helper + * re-validates both name and source before touching apt regardless. */ public async void install_async(string package, string source, Cancellable? cancellable = null) throws Error { if (!Regex.match_simple("^ncz-wallpapers-[a-z0-9][a-z0-9-]*$", package)) { @@ -216,10 +169,6 @@ namespace Singularity { throw new ArtistPackError.INVALID_RESPONSE( "Refusing to install %s: no source URI given".printf(package)); } - // Both binaries below come from compiled-in absolute paths, never - // from PATH: INSTALL_HELPER is the PROGRAM pkexec runs as root, so - // letting the environment decide which file that is would be a - // local privilege escalation. See INSTALL_HELPER's declaration. if (!is_executable_file(INSTALL_HELPER)) { throw new ArtistPackError.BACKEND_MISSING("%s is not installed".printf(INSTALL_HELPER)); } From 2196b51e663201825ee3d346a810c0a70103609d Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Mon, 14 Sep 2026 11:46:30 -0400 Subject: [PATCH 6/6] feat(artist-packs): ship the reference backend and polkit policy Mirko (PR #28 review, on f72af09): the Artist Pack browser gated on a helper interface and polkit policy that weren't shipped in any Singularity repository, so is_available() could never be true for anyone building this from source. Ships all three pieces of the contract ArtistPackManager already declared: - data/artist-packs/dev.sinty.desktop.artist-pack-install.policy - data/artist-packs/singularity-artist-pack-inventory (unprivileged) - data/artist-packs/singularity-artist-pack-install (pkexec target) Renamed from the ncz-wallpaper-pack-{inventory,install} names in the original commit -- those are NCZ-OS's own branding, out of place in a generic Singularity repository (rule 2 in the contribution guidelines). Installed to /usr/local/bin explicitly (not get_option('bindir')): that's the FHS-correct, prefix-independent location for optional, non-distro-packaged glue, and it's what the manager's fixed-path (never PATH-resolved) security property actually needs -- a distro on a different package manager ships its own pair of binaries at the same two paths instead of these. Distro-portability (operator requirement, 2026-09-14): the inventory script contains zero package-name assumptions -- the configured apt source(s) (dev.sinty.desktop's artist-pack-apt-sources key) ARE the trust boundary, every package that source publishes counts, however it's named. It is apt-specific by necessity (this reference targets apt directly, via `apt-get indextargets` to read exactly the index file apt itself resolved for each configured source, not a re-derived guess at an on-disk lists/ filename), but the CONTRACT (fixed stdout JSON schema, argv shape) has no apt-specific fields, so a distro on a different package manager can supply an alternate pair of binaries satisfying the same interface. Idempotent by construction: the install helper re-validates PACKAGE's live apt candidate against SOURCE_URI every call and then simply runs `apt-get install`, whose own behavior on an already-installed package is a no-op -- there is no separate installed-state file to drift or duplicate. Verified: both scripts pass `dash -n` (POSIX sh, not just bash-lenient syntax). Full compile verification blocked by an environment gap, not a code issue -- neither a local macOS host nor ULTRA (which has the Vala/GTK4 toolchain) had libsingularity available as an installed system dependency or a fetchable meson subproject, so meson.build's `dependency('singularity-1.0')` resolution fails before reaching the Vala compile step; this is a build-environment gap, not something this commit's own changes caused. Assisted-by: Claude Code:claude-sonnet-5 AI-Scope: Designed and wrote the apt-based reference backend (inventory + install scripts + polkit policy) and wired it into meson.build in response to Mirko's PR #28 review comment; renamed the helper contract off NCZ-specific naming per the operator's distro-portability requirement. --- ...v.sinty.desktop.artist-pack-install.policy | 16 ++++ .../singularity-artist-pack-install | 55 ++++++++++++ .../singularity-artist-pack-inventory | 89 +++++++++++++++++++ meson.build | 13 +++ src/core/artist_pack_manager.vala | 4 +- 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 data/artist-packs/dev.sinty.desktop.artist-pack-install.policy create mode 100755 data/artist-packs/singularity-artist-pack-install create mode 100755 data/artist-packs/singularity-artist-pack-inventory diff --git a/data/artist-packs/dev.sinty.desktop.artist-pack-install.policy b/data/artist-packs/dev.sinty.desktop.artist-pack-install.policy new file mode 100644 index 0000000..8885c2d --- /dev/null +++ b/data/artist-packs/dev.sinty.desktop.artist-pack-install.policy @@ -0,0 +1,16 @@ + + + + Singularity + + Install an Artist Pack + Authentication is required to install wallpaper packages + preferences-desktop-wallpaper-symbolic + + auth_admin + auth_admin + auth_admin_keep + + + diff --git a/data/artist-packs/singularity-artist-pack-install b/data/artist-packs/singularity-artist-pack-install new file mode 100755 index 0000000..0e4d14a --- /dev/null +++ b/data/artist-packs/singularity-artist-pack-install @@ -0,0 +1,55 @@ +#!/bin/sh +# singularity-artist-pack-install -- privileged Artist Pack installer. +# +# Invoked via pkexec (dev.sinty.desktop.artist-pack-install), so it runs as +# root with the CALLER's argv but none of the caller's session state -- no +# GSettings/dconf, no desktop D-Bus session. It therefore never trusts the +# `source` argument at face value: it re-derives the set of currently +# configured apt sources itself (root-owned, on disk) and refuses to act +# unless PACKAGE's live apt candidate actually comes from a URI in that set. +# This is what makes the contract safe even though a pkexec-elevated process +# cannot re-read the desktop user's artist-pack-apt-sources GSettings key the +# way the unprivileged inventory script does. +# +# Usage: singularity-artist-pack-install PACKAGE SOURCE_URI +# Idempotent: installing an already-installed package at the current +# candidate version is a safe no-op (apt-get install's own behaviour); +# re-running this against the same PACKAGE/SOURCE_URI never duplicates state. +set -eu + +PACKAGE="${1:-}" +SOURCE_URI="${2:-}" +[ -n "$PACKAGE" ] && [ -n "$SOURCE_URI" ] || { + echo "usage: $0 PACKAGE SOURCE_URI" >&2 + exit 2 +} +# Package names are a fixed, narrow charset -- reject anything else outright +# rather than letting it reach apt-get as a crafted argument. +case "$PACKAGE" in + *[!a-zA-Z0-9.+-]*|"") + echo "refusing: '$PACKAGE' is not a valid package name" >&2 + exit 1 + ;; +esac + +command -v apt-cache >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1 || { + echo "apt is not available on this system" >&2 + exit 1 +} + +POLICY=$(apt-cache policy "$PACKAGE" 2>/dev/null || true) +CANDIDATE=$(printf '%s' "$POLICY" | awk '/Candidate:/{print $2; exit}') +[ -n "$CANDIDATE" ] && [ "$CANDIDATE" != "(none)" ] || { + echo "refusing: '$PACKAGE' has no installation candidate" >&2 + exit 1 +} +# The policy block lists one " / ..." line +# per source carrying a version of this package; the candidate's own origin +# must be present among them, and it must match the URI the caller supplied -- +# not merely "some configured source carries this package somewhere". +printf '%s' "$POLICY" | grep -F "$SOURCE_URI" >/dev/null || { + echo "refusing: '$PACKAGE' candidate $CANDIDATE is not associated with source $SOURCE_URI" >&2 + exit 1 +} + +exec apt-get install -y --no-install-recommends "$PACKAGE" diff --git a/data/artist-packs/singularity-artist-pack-inventory b/data/artist-packs/singularity-artist-pack-inventory new file mode 100755 index 0000000..3869dc1 --- /dev/null +++ b/data/artist-packs/singularity-artist-pack-inventory @@ -0,0 +1,89 @@ +#!/bin/sh +# singularity-artist-pack-inventory -- list Artist Packs available from the +# distro-configured apt source(s), plus which are already installed. +# +# Unprivileged. Reads dev.sinty.desktop's `artist-pack-apt-sources` key: a +# list of one-line apt sources ("deb URI SUITE COMPONENT..."). ANY package +# published by a configured source counts as an Artist Pack -- this script +# never filters on package name, so a distro (or a third-party repo) is free +# to name its packages however it likes; the configured source IS the trust +# boundary. An empty/unset key means no configured sources, so an empty JSON +# array is printed, not an error -- ArtistPackManager.is_available() already +# gates whether this section is shown at all. +# +# Package-manager specific by necessity (this build targets apt), but the +# CONTRACT is not: fixed stdout schema, no apt-specific fields leak into it. +# A distro on a different package manager supplies its own binary satisfying +# this same contract at the same fixed path (see ArtistPackManager). +# +# Output: a JSON array of {package, title, summary, version, source, installed} +# on stdout, one line at most per package. Exit 0 even when there is nothing +# to report; non-zero is reserved for a genuine failure to query apt. +set -eu + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e ':a;N;$!ba;s/\n/\\n/g' +} + +SOURCES_RAW=$(gsettings get dev.sinty.desktop artist-pack-apt-sources 2>/dev/null || printf '@as []') +# gsettings prints a GVariant array literal, e.g. ['deb https://...', ...] -- +# one source line per output line. +SOURCE_LINES=$(printf '%s\n' "$SOURCES_RAW" \ + | sed -e "s/^@as //" -e "s/^\[//" -e "s/\]\s*$//" \ + | tr ',' '\n' \ + | sed -e "s/^[[:space:]]*'//" -e "s/'[[:space:]]*$//" \ + | grep -v '^[[:space:]]*$' || true) + +[ -n "$SOURCE_LINES" ] || { printf '[]\n'; exit 0; } +command -v apt-cache >/dev/null 2>&1 || { printf '[]\n'; exit 0; } +command -v apt-get >/dev/null 2>&1 || { printf '[]\n'; exit 0; } + +first=1 +printf '[' +printf '%s\n' "$SOURCE_LINES" | while IFS= read -r source_line; do + [ -n "$source_line" ] || continue + uri=$(printf '%s' "$source_line" | awk '{print $2}') + suite=$(printf '%s' "$source_line" | awk '{print $3}') + [ -n "$uri" ] && [ -n "$suite" ] || continue + + # The index file apt actually resolved for this source, so we read + # exactly what apt itself considers to belong to it -- not a re-derived + # guess at the on-disk lists/ filename. + index_file=$(apt-get indextargets --format '$(FILENAME)' \ + "Repo-URI: $uri" "Codename: $suite" 2>/dev/null | head -1) + [ -n "$index_file" ] && [ -r "$index_file" ] || continue + + pkg="" + version="" + summary="" + while IFS= read -r line; do + case "$line" in + "Package: "*) pkg=${line#Package: } ;; + "Version: "*) version=${line#Version: } ;; + "Description: "*|"Description-en: "*) summary=${line#*: } ;; + "") + [ -n "$pkg" ] || continue + installed=false + dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q '^install ok installed$' && installed=true + [ "$first" = 1 ] || printf ',' + first=0 + printf '{"package":"%s","title":"%s","summary":"%s","version":"%s","source":"%s","installed":%s}' \ + "$(json_escape "$pkg")" "$(json_escape "$pkg")" "$(json_escape "$summary")" \ + "$(json_escape "$version")" "$(json_escape "$uri")" "$installed" + pkg=""; version=""; summary="" + ;; + esac + done < "$index_file" + # A Packages file with no trailing blank line leaves the last stanza + # unflushed by the loop above. + if [ -n "$pkg" ]; then + installed=false + dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q '^install ok installed$' && installed=true + [ "$first" = 1 ] || printf ',' + first=0 + printf '{"package":"%s","title":"%s","summary":"%s","version":"%s","source":"%s","installed":%s}' \ + "$(json_escape "$pkg")" "$(json_escape "$pkg")" "$(json_escape "$summary")" \ + "$(json_escape "$version")" "$(json_escape "$uri")" "$installed" + fi +done +printf ']\n' diff --git a/meson.build b/meson.build index 014ce81..9d9a85d 100644 --- a/meson.build +++ b/meson.build @@ -483,6 +483,19 @@ install_data('data/fan-control/dev.sinty.FanControl.conf', install_data('data/fan-control/dev.sinty.fan-control.policy', install_dir: get_option('datadir') / 'polkit-1' / 'actions') +# Reference Artist Pack backend (apt-based). ArtistPackManager resolves its +# two helpers at FIXED /usr/local/bin paths, never via PATH (privilege- +# escalation reasons documented in artist_pack_manager.vala), so this +# installs there directly rather than through get_option('bindir') -- a +# distro on a different package manager supplies its own pair of binaries +# satisfying the same contract at the same two paths instead of using these. +install_data('data/artist-packs/singularity-artist-pack-inventory', + install_dir: '/usr/local/bin', install_mode: 'rwxr-xr-x') +install_data('data/artist-packs/singularity-artist-pack-install', + install_dir: '/usr/local/bin', install_mode: 'rwxr-xr-x') +install_data('data/artist-packs/dev.sinty.desktop.artist-pack-install.policy', + install_dir: get_option('datadir') / 'polkit-1' / 'actions') + wallpaper_collections_test = executable('wallpaper-collections-test', sources: ['src/core/wallpaper_collections.vala', 'tests/wallpaper_collections_test.vala'], dependencies: [dependency('gobject-2.0'), dependency('glib-2.0'), dependency('gio-2.0'), gee_dep], diff --git a/src/core/artist_pack_manager.vala b/src/core/artist_pack_manager.vala index ccda5dc..cffedc5 100644 --- a/src/core/artist_pack_manager.vala +++ b/src/core/artist_pack_manager.vala @@ -65,8 +65,8 @@ namespace Singularity { * ~/.local/bin) shadow it and get elevated - a local privilege * escalation. */ - private const string INVENTORY_HELPER = "/usr/local/bin/ncz-wallpaper-pack-inventory"; - private const string INSTALL_HELPER = "/usr/local/bin/ncz-wallpaper-pack-install"; + private const string INVENTORY_HELPER = "/usr/local/bin/singularity-artist-pack-inventory"; + private const string INSTALL_HELPER = "/usr/local/bin/singularity-artist-pack-install"; private const string INSTALL_POLICY = "/usr/share/polkit-1/actions/dev.sinty.desktop.artist-pack-install.policy"; public static ArtistPackManager get_default() {