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 8b9866c..9d9a85d 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', @@ -482,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/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index 6127510..a1649e7 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -28,6 +28,12 @@ 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; + // 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 @@ -286,6 +292,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 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. + 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 +1942,228 @@ 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; + // 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"))); + 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); + } + } + + // 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..."); + 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(); diff --git a/src/core/artist_pack_manager.vala b/src/core/artist_pack_manager.vala new file mode 100644 index 0000000..cffedc5 --- /dev/null +++ b/src/core/artist_pack_manager.vala @@ -0,0 +1,191 @@ +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). + * + * 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 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() 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 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/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() { + if (_instance == null) _instance = new ArtistPackManager(); + return _instance; + } + + private ArtistPackManager() { } + + /** 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. */ + 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; + } + + /** True only when the distro provides the complete backend contract. */ + 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; + } + + /** + * 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(); + 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; + } + + /** + * Installs one Artist Pack via pkexec + the distro's install helper. + * + * `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)) { + 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)); + } + 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())); + } + } + } +}