From 29d1f1977891a60d7b834dc15d3754c295431d9a Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Thu, 17 Sep 2026 10:38:13 -0400 Subject: [PATCH 1/2] feat(desktop): Artist Pack browser, gated on a packaged backend Rebased onto main: #25, #26 and #27 landed the wallpaper stack this branch had been carrying a pre-merge copy of, so only the Artist Pack additions remain here. Drops the ncz-wallpapers-* package-name regex from install_async(); the helper contract has no package-name assumptions, so neither should the shell. Validation is now a generic package-name charset, matching the install helper's own check. Comment density cut to one line per comment across the whole diff. Assisted-by: Claude Code:claude-opus-5 AI-Scope: Rebase conflict resolution against merged #25/#26/#27, comment trimming, and dropping the distro-specific package regex. --- ...v.sinty.desktop.artist-pack-install.policy | 16 ++ .../singularity-artist-pack-install | 35 +++++ .../singularity-artist-pack-inventory | 68 +++++++++ meson.build | 10 ++ .../sidebar/pages/desktop_page.vala | 103 +++++++++++++ src/core/artist_pack_manager.vala | 143 ++++++++++++++++++ 6 files changed, 375 insertions(+) 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 create mode 100644 src/core/artist_pack_manager.vala 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..94473e5 --- /dev/null +++ b/data/artist-packs/singularity-artist-pack-install @@ -0,0 +1,35 @@ +#!/bin/sh +# Usage: singularity-artist-pack-install PACKAGE SOURCE_URI (via pkexec). +# Root has no dconf session, so apt's own policy must confirm SOURCE_URI. +set -eu + +PACKAGE="${1:-}" +SOURCE_URI="${2:-}" +[ -n "$PACKAGE" ] && [ -n "$SOURCE_URI" ] || { + echo "usage: $0 PACKAGE SOURCE_URI" >&2 + exit 2 +} +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 +} +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..1ccaabe --- /dev/null +++ b/data/artist-packs/singularity-artist-pack-inventory @@ -0,0 +1,68 @@ +#!/bin/sh +# Unprivileged. Prints a JSON array of +# {package,title,summary,version,source,installed} for every package the +# dev.sinty.desktop artist-pack-apt-sources entries publish. +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 literal: ['deb https://...', ...] +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 + + # Ask apt which index it resolved, rather than guessing the lists/ name. + 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 without a trailing blank line leaves the last stanza unflushed. + 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 d0b09a7..b1d2427 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/wallpaper_sidecar.vala', 'src/core/wallpaper_ocs.vala', 'src/core/wallpaper_browse_cache.vala', @@ -490,6 +491,15 @@ 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 apt-based Artist Pack backend. ArtistPackManager resolves these +# at fixed /usr/local/bin paths, never via PATH, so install there directly. +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, json_dep], diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index b5777b2..4b53ea2 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -32,6 +32,10 @@ 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; + // In-flight installs; survives the row teardown a refresh performs. + 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 @@ -432,6 +436,19 @@ namespace Singularity { add_group(grid_group); GLib.Idle.add(() => { populate_grid(); return GLib.Source.REMOVE; }); + + 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(); @@ -2223,6 +2240,92 @@ namespace Singularity { } } + 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; + // A transaction started before this refresh is not yet in pack.installed. + 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(() => { + start_artist_pack_install(captured_package, captured_source, captured_btn); + }); + row.add_suffix(install_btn); + artist_pack_group.add_row(row); + } + } + + private void start_artist_pack_install(string package, string source, Button btn) { + if (!artist_packs_installing.add(package)) return; + // A refresh mid-install detaches btn's row, so relabel only if gen holds. + 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(); + } + // The new pack's .collection file is only re-read here. + 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(); diff --git a/src/core/artist_pack_manager.vala b/src/core/artist_pack_manager.vala new file mode 100644 index 0000000..985fe83 --- /dev/null +++ b/src/core/artist_pack_manager.vala @@ -0,0 +1,143 @@ +using GLib; +using Gee; +using Json; + +namespace Singularity { + + 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, + } + + public class ArtistPackManager : GLib.Object { + private static ArtistPackManager? _instance = null; + + // Never resolved via PATH: INSTALL_HELPER is what pkexec runs as root, + // so a PATH lookup would let ~/.local/bin shadow it and get elevated. + 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"; + private const string[] PKEXEC_PATHS = { "/usr/bin/pkexec", "/bin/pkexec" }; + + public static ArtistPackManager get_default() { + if (_instance == null) _instance = new ArtistPackManager(); + return _instance; + } + + private ArtistPackManager() { } + + private static bool is_executable_file(string path) { + return FileUtils.test(path, FileTest.IS_REGULAR) + && FileUtils.test(path, FileTest.IS_EXECUTABLE); + } + + private static string? find_pkexec() { + foreach (unowned string candidate in PKEXEC_PATHS) { + if (is_executable_file(candidate)) return candidate; + } + return null; + } + + // Every piece of the contract, so an incomplete backend shows nothing. + public bool is_available() { + return is_executable_file(INVENTORY_HELPER) + && is_executable_file(INSTALL_HELPER) + && FileUtils.test(INSTALL_POLICY, FileTest.IS_REGULAR) + && find_pkexec() != null; + } + + // Absent backend yields an empty list, not an error. + public async Gee.ArrayList fetch_inventory_async(Cancellable? cancellable = null) throws Error { + var results = new Gee.ArrayList(); + if (!is_executable_file(INVENTORY_HELPER)) return results; + + 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); + 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; + } + + // `source` must be the value fetch_inventory_async() reported; the + // privileged helper re-validates it and does not trust this argv. + public async void install_async(string package, string source, Cancellable? cancellable = null) throws Error { + if (!Regex.match_simple("^[a-z0-9][a-z0-9.+-]*$", package)) { + throw new ArtistPackError.INVALID_RESPONSE( + "Refusing to install %s: not a valid package name".printf(package)); + } + if (source.strip().length == 0) { + throw new ArtistPackError.INVALID_RESPONSE( + "Refusing to install %s: no source URI given".printf(package)); + } + if (!is_executable_file(INSTALL_HELPER)) { + throw new ArtistPackError.BACKEND_MISSING("%s is not installed".printf(INSTALL_HELPER)); + } + string? pkexec = find_pkexec(); + if (pkexec == null) { + throw new ArtistPackError.BACKEND_MISSING("pkexec is not available"); + } + + var proc = new Subprocess(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE, + pkexec, INSTALL_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 0c3ce6a7b891ac9b3f4014de123dcceb62ee3980 Mon Sep 17 00:00:00 2001 From: Mirko Brombin Date: Fri, 18 Sep 2026 10:30:48 +0200 Subject: [PATCH 2/2] cleanup: explicit comments --- meson.build | 2 -- 1 file changed, 2 deletions(-) diff --git a/meson.build b/meson.build index b1d2427..cbd7cbd 100644 --- a/meson.build +++ b/meson.build @@ -491,8 +491,6 @@ 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 apt-based Artist Pack backend. ArtistPackManager resolves these -# at fixed /usr/local/bin paths, never via PATH, so install there directly. 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',