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..cbd7cbd 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,13 @@ 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')
+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()));
+ }
+ }
+ }
+}