diff --git a/meson.build b/meson.build
index 8b9866c..d0b09a7 100644
--- a/meson.build
+++ b/meson.build
@@ -22,7 +22,7 @@ if get_option('buildtype').startswith('release')
endif
core_deps = [
- dependency('gtk4'),
+ dependency('gtk4', version: '>= 4.10'),
dependency('gio-2.0'),
dependency('gio-unix-2.0'),
dependency('vte-2.91-gtk4'),
@@ -286,6 +286,12 @@ singularity_core_sources = files(
'src/core/wallpaper_gallery.vala',
'src/core/wallpaper_rotation_state.vala',
'src/core/wallpaper_rotator.vala',
+ 'src/core/wallpaper_sidecar.vala',
+ 'src/core/wallpaper_ocs.vala',
+ 'src/core/wallpaper_browse_cache.vala',
+ 'src/core/wallpaper_thumbnail_cache.vala',
+ 'src/core/wallpaper_provider.vala',
+ 'src/core/settings_safety.vala',
'src/core/wayland_gamma_backend.vala',
'src/core/shortcut_manager.vala',
'src/core/ush_portal.vala',
@@ -313,6 +319,8 @@ singularity_core_sources = files(
'src/components/sidebar/views/settings_view.vala',
'src/components/sidebar/pages/network_page.vala',
'src/components/sidebar/pages/desktop_page.vala',
+ 'src/components/sidebar/pages/wallpaper_ocs_browser.vala',
+ 'src/components/sidebar/pages/provider_credential_group.vala',
'src/components/sidebar/pages/sound_page.vala',
'src/components/sidebar/pages/keyboard_page.vala',
'src/components/sidebar/pages/accessibility_page.vala',
@@ -484,7 +492,7 @@ install_data('data/fan-control/dev.sinty.fan-control.policy',
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],
+ dependencies: [dependency('gobject-2.0'), dependency('glib-2.0'), dependency('gio-2.0'), gee_dep, json_dep],
)
test('wallpaper-collections', wallpaper_collections_test)
@@ -499,11 +507,28 @@ wallpaper_gallery_test = executable('wallpaper-gallery-test',
dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), gee_dep],
)
test('wallpaper-gallery', wallpaper_gallery_test)
-
wallpaper_rotator_test = executable('wallpaper-rotator-test',
sources: ['src/core/wallpaper_collections.vala', 'src/core/wallpaper_gallery.vala',
'src/core/wallpaper_rotation_state.vala', 'src/core/wallpaper_rotator.vala',
'tests/wallpaper_rotator_test.vala'],
- dependencies: [dependency('gobject-2.0'), dependency('glib-2.0'), dependency('gio-2.0'), gee_dep],
+ dependencies: [dependency('gobject-2.0'), dependency('glib-2.0'), dependency('gio-2.0'), gee_dep, json_dep],
)
test('wallpaper-rotator', wallpaper_rotator_test)
+
+wallpaper_ocs_test = executable('wallpaper-ocs-test',
+ sources: ['src/core/wallpaper_ocs.vala', 'src/core/wallpaper_browse_cache.vala', 'src/core/wallpaper_thumbnail_cache.vala', 'src/core/wallpaper_provider.vala', 'src/core/wallpaper_collections.vala', 'tests/wallpaper_ocs_test.vala'],
+ dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), gee_dep, json_dep],
+)
+test('wallpaper-ocs', wallpaper_ocs_test)
+
+wallpaper_sidecar_test = executable('wallpaper-sidecar-test',
+ sources: ['src/core/wallpaper_sidecar.vala', 'tests/wallpaper_sidecar_test.vala'],
+ dependencies: [dependency('gobject-2.0'), dependency('gio-2.0'), gee_dep, json_dep],
+)
+test('wallpaper-sidecar', wallpaper_sidecar_test)
+
+settings_safety_test = executable('settings-safety-test',
+ sources: ['src/core/settings_safety.vala', 'tests/settings_safety_test.vala'],
+ dependencies: [dependency('gobject-2.0'), dependency('gio-2.0')],
+)
+test('settings-safety', settings_safety_test)
diff --git a/src/components/background/background.vala b/src/components/background/background.vala
index 510d6cb..70caf21 100644
--- a/src/components/background/background.vala
+++ b/src/components/background/background.vala
@@ -1,5 +1,8 @@
using Gtk;
using GtkLayerShell;
+// GLib.Markup.escape_text -- used by the attribution overlay to safely
+// embed third-party OCS/Bing caption text in a Pango-markup Label.
+using GLib;
namespace Singularity {
@@ -9,6 +12,54 @@ namespace Singularity {
private Stack wp_stack;
private bool _wp_showing_a = true;
private uint _wp_clear_id = 0;
+ // Live-toggle for the attribution overlay. Background.vala reads
+ // show-wallpaper-attribution and routes through the existing
+ // empty-title-and-empty-author early-return path when false, so
+ // toggling it live (via the desktop settings page) hides or
+ // re-shows the overlay without waiting for the next wallpaper
+ // change. The schema id matches the rest of the shell
+ // (desktop_page.vala initialises the same way).
+ private GLib.Settings settings;
+
+ // Attribution overlay. wp_stack is the wallpaper cross-fade;
+ // the attribution Label sits on top of it in a Gtk.Overlay so
+ // the wallpaper texture is the lower layer and the text is
+ // painted over the corner of the screen. The label is hidden
+ // when both WallpaperManager.attribution_title and
+ // attribution_author are empty (the schema-default state and
+ // the explicit-clear state at every background-picture-uri
+ // write site).
+ private Gtk.Overlay? wp_overlay;
+ private Label attribution_label;
+ // Loads the attribution-label CSS once per process. Static +
+ // null-guarded the same way panel.vala's compact_rows_provider is,
+ // since Background windows are created per-monitor and the rules
+ // are process-global, not per-instance.
+ private static Gtk.CssProvider? attribution_css_provider = null;
+ // Corner-sample parameters. Sampled as a fractional rect inside
+ // the cached medium pixbuf so the sample tracks whatever size
+ // the manager uses for its display texture (currently 320x180,
+ // but the manager owns that decision). Bottom-left, 40% width x
+ // 30% height, with a small margin from the very edge so the
+ // sample doesn't include the empty space around the label.
+ private const double CORNER_SAMPLE_X_FRAC = 0.0;
+ private const double CORNER_SAMPLE_Y_FRAC = 0.65;
+ private const double CORNER_SAMPLE_W_FRAC = 0.40;
+ private const double CORNER_SAMPLE_H_FRAC = 0.30;
+ // Pixel margin from the screen edge to the attribution label.
+ // Bottom-left is clear of the dock (which is bottom-anchored
+ // and horizontally centered) at any reasonable screen width,
+ // but a 24px gutter keeps the scrim from clipping into the
+ // screen edge on rounded displays / ultrawide aspects.
+ private const int ATTRIBUTION_MARGIN = 24;
+ // topbar_lum_threshold -- copied from panel.vala (kept in sync
+ // with that constant by convention rather than a shared header,
+ // matching how the rest of the codebase pairs these CSS-class
+ // and contrast decisions). Above this luminance the wallpaper
+ // under the corner is "light", and the overlay switches to
+ // dark text; below it stays light text. 0.72 matches the
+ // value panel.vala uses for the top band.
+ private const double ATTRIBUTION_LUM_THRESHOLD = 0.72;
public signal void first_painted();
private bool _first_painted_done = false;
@@ -28,6 +79,7 @@ namespace Singularity {
add_css_class("singularity");
add_css_class("singularity-shell");
add_css_class("background-window");
+ ensure_attribution_css();
picture_a = new Picture();
picture_a.content_fit = ContentFit.COVER;
@@ -39,9 +91,48 @@ namespace Singularity {
wp_stack.transition_duration = 600;
wp_stack.add_named(picture_a, "a");
wp_stack.add_named(picture_b, "b");
- set_child(wp_stack);
+
+ // Attribution overlay. The label is bottom-left-anchored
+ // (halign=START, valign=END, ATTRIBUTION_MARGIN gutter)
+ // and click-through so it never intercepts desktop mouse
+ // events. can_target=false is GTK4's correct way to make a
+ // widget hit-test-transparent; setting can_focus=false
+ // prevents the label from grabbing Tab focus out of the
+ // desktop. The scrim + padding + font live in the
+ // `attribution-label` CSS class, loaded by
+ // ensure_attribution_css() above (wallpaper-specific styling
+ // lives here, not in libsingularity, per review on
+ // libsingularity#13). The `light-bg` class on the Background
+ // window (toggled below) is the same one panel.vala uses, so
+ // the contrast rule is consistent across panel + overlay.
+ attribution_label = new Label("");
+ attribution_label.add_css_class("attribution-label");
+ attribution_label.halign = Align.START;
+ attribution_label.valign = Align.END;
+ attribution_label.xalign = 0.0f;
+ attribution_label.yalign = 1.0f;
+ attribution_label.margin_start = ATTRIBUTION_MARGIN;
+ attribution_label.margin_end = ATTRIBUTION_MARGIN;
+ attribution_label.margin_bottom = ATTRIBUTION_MARGIN;
+ attribution_label.margin_top = ATTRIBUTION_MARGIN;
+ attribution_label.visible = false;
+ attribution_label.can_focus = false;
+ attribution_label.can_target = false;
+ wp_overlay = new Gtk.Overlay();
+ wp_overlay.set_child(wp_stack);
+ wp_overlay.add_overlay(attribution_label);
+ set_child(wp_overlay);
var manager = WallpaperManager.get_default();
+ // GSettings backing for the attribution toggle. Same schema id
+ // string as desktop_page.vala (dev.sinty.desktop). The
+ // changed[] handler re-runs update_attribution() so flipping
+ // the toggle in Settings immediately hides or re-shows the
+ // overlay for the wallpaper that's currently displayed.
+ settings = new GLib.Settings("dev.sinty.desktop");
+ settings.changed["show-wallpaper-attribution"].connect(() => {
+ update_attribution(WallpaperManager.get_default());
+ });
// First load: set both pictures to avoid flash, no animation needed
if (manager.display_texture != null) {
picture_a.set_paintable(manager.display_texture);
@@ -50,7 +141,13 @@ namespace Singularity {
}
manager.wallpaper_changed.connect(() => {
update_wallpaper(manager);
+ update_attribution(manager);
});
+ // Initial bind for the case where WallpaperManager already
+ // has a display_texture and attribution on startup (warm
+ // restart): wallpaper_changed would not re-fire, so we
+ // call update_attribution() once explicitly.
+ update_attribution(manager);
map.connect_after(() => {
if (_first_painted_done) return;
var clock = get_frame_clock();
@@ -97,6 +194,137 @@ namespace Singularity {
});
}
+ // Wallpaper attribution overlay (Background.vala).
+ //
+ // Sits in the bottom-left corner of the live desktop background
+ // as a single Gtk.Label over the wallpaper cross-fade. The scrim
+ // is a semi-transparent rounded rectangle so the text reads
+ // against both bright and dark wallpapers without an aggressive
+ // box, and the light-bg / non-light-bg pair matches the
+ // convention panel.vala already uses for the top band -- same
+ // colour tokens, same threshold (ATTRIBUTION_LUM_THRESHOLD = 0.72,
+ // defined above).
+ //
+ // The luminance class selects an opposing scrim/text pair
+ // independently of the active application theme, since wallpaper
+ // contrast cannot be inferred from the theme's text colour.
+ //
+ // Moved here from libsingularity's style.css (review on
+ // libsingularity#13: that stylesheet should stay limited to
+ // reusable widget styling, and this rule only exists for the
+ // wallpaper attribution overlay singularity-shell owns) -- rules
+ // and rationale unchanged, just relocated to the actual consumer.
+ private const string ATTRIBUTION_CSS = """
+.background-window .attribution-label {
+ border-radius: 8px;
+ padding: 6px 12px;
+ font-size: 13px;
+ font-weight: 400;
+}
+.background-window.light-bg .attribution-label {
+ /* Bright wallpaper: dark scrim with light foreground. */
+ background-color: alpha(black, 0.65);
+ color: white;
+ text-shadow: 0 1px 2px alpha(black, 0.45);
+}
+.background-window:not(.light-bg) .attribution-label {
+ /* Dark wallpaper: light scrim with dark foreground. */
+ background-color: alpha(white, 0.72);
+ color: black;
+ text-shadow: 0 1px 2px alpha(white, 0.35);
+}
+""";
+
+ // Registers ATTRIBUTION_CSS once per process, the same way
+ // panel.vala's compact_rows_provider is registered: a static
+ // nullable CssProvider, guarded by a null-check, loaded on first
+ // Background construction.
+ private static void ensure_attribution_css() {
+ if (attribution_css_provider != null) return;
+ var display = Gdk.Display.get_default();
+ if (display == null) return;
+ attribution_css_provider = new Gtk.CssProvider();
+ attribution_css_provider.load_from_string(ATTRIBUTION_CSS);
+ Gtk.StyleContext.add_provider_for_display(
+ display,
+ attribution_css_provider,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+ );
+ }
+
+ // Bind the attribution overlay to the WallpaperManager. Called
+ // on every wallpaper_changed signal -- which now fires on URI
+ // changes AND on attribution-only changes (see WallpaperManager
+ // reload()), so a single signal covers both cases.
+ //
+ // Text format: title (bold) and author (dim-label) concatenated
+ // with a middle-dot separator. Markup-escape both because the
+ // data comes from third-party OCS / Bing caption strings that
+ // the parsers accept leniently -- an unescaped & < > in the
+ // text would otherwise be a Pango parse error and crash the
+ // label render.
+ //
+ // Contrast: sample the bottom-left corner rectangle from
+ // WallpaperManager.corner_luminance_frac() and compare against
+ // ATTRIBUTION_LUM_THRESHOLD. Above threshold = light
+ // background under the text = use dark text via .light-bg
+ // class on the Background window; below = dark background =
+ // use light text. Same `light-bg` CSS class the panel uses,
+ // extended in the stylesheet for .background-window.light-bg.
+ private void update_attribution(WallpaperManager manager) {
+ string title = manager.attribution_title ?? "";
+ string author = manager.attribution_author ?? "";
+ // The user-toggleable show-wallpaper-attribution gsettings key
+ // shares the same early-return path as the no-title-and-no-author
+ // case below: when the overlay is hidden for any reason we
+ // clear the contrast class too, so re-enabling the toggle (or
+ // loading a wallpaper that carries attribution) re-samples
+ // cleanly on the next wallpaper_changed.
+ if (!settings.get_boolean("show-wallpaper-attribution")) {
+ title = "";
+ author = "";
+ }
+ if (title == "" && author == "") {
+ attribution_label.visible = false;
+ attribution_label.label = "";
+ // Remove the contrast class too: a hidden overlay
+ // shouldn't keep the .light-bg class set on the
+ // window, because if a future wallpaper is loaded
+ // without attribution we still want the window to
+ // re-sample cleanly on the next wallpaper_changed.
+ remove_css_class("light-bg");
+ return;
+ }
+ string safe_title = Markup.escape_text(title, -1);
+ string safe_author = Markup.escape_text(author, -1);
+ string markup;
+ if (title != "" && author != "") {
+ markup = "%s · %s".printf(safe_title, safe_author);
+ } else if (title != "") {
+ markup = "%s".printf(safe_title);
+ } else {
+ markup = safe_author;
+ }
+ // CSS class is not a supported Pango span attribute.
+ attribution_label.set_markup(markup);
+ attribution_label.visible = true;
+
+ // Sample the corner. The pixbuf aspect matches the screen
+ // aspect so a fractional bottom-left corner maps 1:1 to a
+ // fractional bottom-left corner of the screen at the same
+ // proportional position.
+ double lum = manager.corner_luminance_frac(
+ CORNER_SAMPLE_X_FRAC,
+ CORNER_SAMPLE_Y_FRAC,
+ CORNER_SAMPLE_W_FRAC,
+ CORNER_SAMPLE_H_FRAC);
+ if (lum >= 0.0) {
+ bool light_bg = lum > ATTRIBUTION_LUM_THRESHOLD;
+ if (light_bg) add_css_class("light-bg");
+ else remove_css_class("light-bg");
+ }
+ }
+
private void update_wallpaper(WallpaperManager manager) {
if (manager.display_texture == null) return;
// Write to the off-screen picture, then crossfade to it
diff --git a/src/components/desktop/desktop_icons.vala b/src/components/desktop/desktop_icons.vala
index ea79ca0..d93c74c 100644
--- a/src/components/desktop/desktop_icons.vala
+++ b/src/components/desktop/desktop_icons.vala
@@ -583,7 +583,16 @@ namespace Singularity {
}
if (content_type.has_prefix("image/")) {
menu.add_item("Set as Wallpaper", "preferences-desktop-wallpaper-symbolic", () => {
- settings.set_string("background-picture-uri", file.get_uri());
+ // A drag-and-drop file from the file manager
+ // has no OCS/Bing metadata, so the attribution
+ // overlay has nothing to show for this URI.
+ // Explicit clear (rather than rely on the
+ // schema default) so a previous OCS-imported
+ // wallpapers title/author cannot bleed through
+ // and appear over this new image.
+ SettingsSafety.set_string(settings, "background-picture-uri", file.get_uri());
+ SettingsSafety.set_string(settings, "background-attribution-title", "");
+ SettingsSafety.set_string(settings, "background-attribution-author", "");
});
}
menu.add_separator();
diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala
index 6127510..b5777b2 100644
--- a/src/components/sidebar/pages/desktop_page.vala
+++ b/src/components/sidebar/pages/desktop_page.vala
@@ -8,6 +8,7 @@ namespace Singularity {
public class DesktopPage : SettingsPage {
private GLib.Settings settings;
private GLib.Settings? wm_settings;
+ private SettingsView view;
private bool decorations_updating_ui = false;
private bool decorations_ignore_change = false;
private Box? decorations_start_box;
@@ -21,6 +22,9 @@ namespace Singularity {
private SelectionRow? decorations_side_row;
private WallpaperPreviewWidget preview_widget;
private FlowBox wallpaper_grid;
+ private Gtk.Box wallpaper_source_container;
+ private string[] wallpaper_collection_roots;
+
private Gee.ArrayList wallpaper_collections = new Gee.ArrayList();
private WallpaperRotationState rotation_state = new WallpaperRotationState(
WallpaperRotationState.default_config_dir());
@@ -44,6 +48,67 @@ namespace Singularity {
return ngettext("Every %d second (custom)", "Every %d seconds (custom)", seconds).printf(seconds);
}
+ // Bing preferred-region selector. The UI side of cix-installer's
+ // 45-wallpaper-rotator.sh's ncz-wallpaper-bing contract -- the
+ // rotator script always fetches and combines EVERY market now
+ // (operator 2026-09-12: "just have it be the preferred language,
+ // and have all the feeds be combined"). The file this writes,
+ // ~/.config/ncz-wallpaper/bing-markets, no longer restricts which
+ // markets are fetched; it only tells the rotator which region's
+ // copy of a photo to prefer when the SAME photograph is served to
+ // more than one market and has to be de-duplicated down to one
+ // (see cmd_consolidate()'s preferred_market() in the rotator
+ // script). The literal "all" (case-insensitive) sentinel, or an
+ // absent file, means "no preference" -- the rotator falls back to
+ // its original alphabetical dedup-winner order.
+ //
+ // The picker is one SelectionRow whose expanded list is "All
+ // Markets, No Preference" followed by all 13 individual markets
+ // (operator 2026-09-13: a popup ConfirmDialog was rejected in
+ // favor of the row expanding INLINE in place, matching every
+ // other single-choice setting on this page). Picking any row
+ // collapses the expander and writes that choice immediately --
+ // there is no separate "Apply" step and no dialog object.
+ private const string BING_MARKETS_ID_ALL = "all";
+ // 13 markets, grouped by region. Order matches the comment block
+ // in cix-installer/post-install/45-wallpaper-rotator.sh's
+ // ncz-wallpaper-bing (Americas, Europe, Asia-Pacific); the group
+ // order is preserved in the flat picker list below so markets
+ // from the same region still sit together even without a
+ // section header.
+ // [0] = market code, [1] = display label, [2] = region header.
+ private const string BING_MARKETS_TABLE = "en-US\tUnited States\tAmericas"
+ + "|en-CA\tCanada English\tAmericas"
+ + "|fr-CA\tCanada French\tAmericas"
+ + "|pt-BR\tBrazil\tAmericas"
+ + "|en-GB\tUnited Kingdom\tEurope"
+ + "|fr-FR\tFrance\tEurope"
+ + "|de-DE\tGermany\tEurope"
+ + "|es-ES\tSpain\tEurope"
+ + "|it-IT\tItaly\tEurope"
+ + "|en-IN\tIndia\tAsia-Pacific"
+ + "|ja-JP\tJapan\tAsia-Pacific"
+ + "|zh-CN\tChina\tAsia-Pacific"
+ + "|ko-KR\tSouth Korea\tAsia-Pacific";
+ private Gee.ArrayList bing_markets_rows = new Gee.ArrayList();
+ private SelectionRow? bing_markets_row = null;
+ private bool bing_markets_updating = false;
+
+ // Lower-case an ASCII string. Vala's GLib string has no public
+ // lowercase() (only casefold(), which is Unicode-aware and
+ // therefore locale-sensitive -- the bing-market codes are all
+ // ISO 639-1 + ISO 3166-1 letters, so a literal ASCII fold is
+ // both correct and cheaper).
+ private static string ascii_lower(string s) {
+ string out = "";
+ for (int i = 0; i < s.length; i++) {
+ char c = s[i];
+ if (c >= 'A' && c <= 'Z') c = (char)(c + 32);
+ out += c.to_string();
+ }
+ return out;
+ }
+
// Appends a rounded-rectangle sub-path to the Cairo context.
private static void round_rect(Cairo.Context ctx, double x, double y, double w, double h, double r) {
double PI = Math.PI;
@@ -87,6 +152,7 @@ namespace Singularity {
base(_("Desktop"));
ensure_wallpaper_css();
settings = new GLib.Settings("dev.sinty.desktop");
+ this.view = view;
back_clicked.connect(() => {
view.go_home();
});
@@ -129,56 +195,69 @@ namespace Singularity {
reset_btn.tooltip_text = _("Reset to Default");
reset_btn.add_css_class("navigation-button");
reset_btn.clicked.connect(() => {
+ // Reset ALL three wallpaper keys so the desktop
+ // returns to a true default state. settings.reset()
+ // is per-key, so the attribution keys do not
+ // auto-reset when only background-picture-uri is
+ // reset -- without the explicit resets below, the
+ // overlay would keep showing stale attribution
+ // from the previous wallpaper even after the user
+ // clicked "Reset to Default".
+ // Stage all three writes as one atomic dconf transaction --
+ // committing them separately fired three independent
+ // "changed" signals in a row, and WallpaperManager.reload()
+ // (which listens to all three keys) ran once per signal,
+ // rendering a visibly flickering sequence of mismatched
+ // picture/attribution combinations before settling on the
+ // final, correct state.
+ settings.delay();
settings.reset("background-picture-uri");
+ SettingsSafety.set_string(settings, "background-attribution-title", "");
+ SettingsSafety.set_string(settings, "background-attribution-author", "");
+ settings.apply();
update_preview();
});
header.append(reset_btn);
var preview_group = new PreferencesGroup(_("Current Wallpaper"));
var preview_widget = new WallpaperPreviewWidget();
preview_widget.select_clicked.connect(() => {
- int64 ts = GLib.get_real_time();
- // Hand the result back through the per-user runtime dir (0700)
- // rather than a predictable name in world-writable /tmp.
- string rdir = GLib.Path.build_filename(GLib.Environment.get_user_runtime_dir(), "singularity");
- GLib.DirUtils.create_with_parents(rdir, 0700);
- string result_path = GLib.Path.build_filename(rdir, "wallpaper-%lld.uris".printf(ts));
- try {
- string exe = GLib.FileUtils.read_link("/proc/self/exe");
- string exe_dir = GLib.Path.get_dirname(exe);
- string files_bin = GLib.Path.build_filename(exe_dir, "singularity-files");
- if (!GLib.FileUtils.test(files_bin, GLib.FileTest.IS_EXECUTABLE)) {
- files_bin = "singularity-files";
+ var dialog = new Gtk.FileDialog();
+ dialog.title = _("Select Wallpaper");
+ var images = new Gtk.FileFilter();
+ images.name = _("Images");
+ images.add_pixbuf_formats();
+ var filters = new GLib.ListStore(typeof(Gtk.FileFilter));
+ filters.append(images);
+ dialog.filters = filters;
+ dialog.default_filter = images;
+ // Passing a parent window here makes GTK export this
+ // window's surface via the xdg-foreign-v2 protocol
+ // (zxdg_exporter_v2.export_toplevel) so the out-of-process
+ // portal file chooser can set itself transient-for it.
+ // Live-reproduced and root-caused on O6N (NCZ-OS, labwc
+ // compositor) via WAYLAND_DEBUG=1: labwc advertises
+ // zxdg_exporter_v2 but disconnects the client instead of
+ // replying with zxdg_exported_v2.handle to that exact
+ // request -- a fatal, unrecoverable Wayland protocol error
+ // (GTK's own internal handling calls exit(); confirmed via
+ // gdb backtrace, no application code anywhere in the
+ // crashing frames). Passing null skips the xdg-foreign
+ // export entirely: the chooser opens as an ordinary
+ // top-level instead of transient-for the main window,
+ // which is a real, supported GtkFileDialog usage pattern
+ // (not a hack), at the minor cost of losing that window
+ // stacking/transiency relationship on compositors where
+ // xdg-foreign actually works correctly.
+ dialog.open.begin(null, null, (obj, result) => {
+ try {
+ var file = dialog.open.end(result);
+ set_wallpaper(file.get_uri());
+ } catch (Gtk.DialogError.DISMISSED e) {
+ // The user dismissed the portal chooser.
+ } catch (Error e) {
+ warning("Wallpaper picker failed: %s", e.message);
}
- var launcher = new GLib.SubprocessLauncher(
- GLib.SubprocessFlags.STDIN_INHERIT |
- GLib.SubprocessFlags.STDOUT_SILENCE |
- GLib.SubprocessFlags.STDERR_SILENCE
- );
- launcher.setenv("SINGULARITY_PORTAL_RESULT_FILE", result_path, true);
- string[] argv = { files_bin, "--portal-mode", "--title=Select Wallpaper" };
- var proc = launcher.spawnv(argv);
- proc.wait_async.begin(null, (obj, res) => {
- try { proc.wait_async.end(res); } catch (Error e) {}
- if (GLib.FileUtils.test(result_path, GLib.FileTest.EXISTS)) {
- try {
- string content;
- GLib.FileUtils.get_contents(result_path, out content);
- GLib.FileUtils.unlink(result_path);
- foreach (var line in content.strip().split("\n")) {
- string uri = line.strip();
- if (uri.length > 0) {
- set_wallpaper(uri);
- break;
- }
- }
- } catch (Error e) {
- GLib.FileUtils.unlink(result_path);
- }
- }
- });
- } catch (Error e) {
- warning("Wallpaper picker: could not launch singularity-files: %s", e.message);
- }
+ });
});
this.preview_widget = preview_widget;
var preview_row = new PreferencesRow();
@@ -187,31 +266,22 @@ namespace Singularity {
add_group(preview_group);
var grid_group = new PreferencesGroup(_("Wallpapers"));
- wallpaper_collections = WallpaperCollections.parse(
- WallpaperCollections.default_search_roots());
+ wallpaper_collection_roots = compute_collection_roots();
+ wallpaper_source_container = new Gtk.Box(Orientation.VERTICAL, 0);
+ var source_container_row = new PreferencesRow();
+ source_container_row.set_child(wallpaper_source_container);
+ grid_group.add_row(source_container_row);
+ refresh_wallpaper_sources();
- var source_options = new Gee.ArrayList();
- foreach (var collection in wallpaper_collections) {
- string label = (collection.artist != null && collection.artist != "" && collection.artist != collection.name)
- ? "%s - %s".printf(collection.name, collection.artist)
- : collection.name;
- source_options.add(new Singularity.Core.AppSettingOption() {
- id = collection.id, label = label
- });
- }
- string initial_collection_id = rotation_state.get_selected_collection("");
- bool have_initial = false;
- foreach (var opt in source_options) if (opt.id == initial_collection_id) have_initial = true;
- if (!have_initial && source_options.size > 0) initial_collection_id = source_options[0].id;
-
- var source_row = new SelectionRow.with_options(
- _("Wallpaper Source"), source_options, initial_collection_id);
- source_row.subtitle = _("Which installed collection the gallery below shows");
- source_row.selected.connect((id) => {
- rotation_state.set_selected_collection(id);
- populate_grid();
+ var online_row = new PreferencesRow();
+ var online_button = new Button.with_label(_("Browse Online Wallpapers"));
+ online_button.margin_start = online_button.margin_end = 10;
+ online_button.margin_top = online_button.margin_bottom = 8;
+ online_button.clicked.connect(() => {
+ view.navigate_to("wallpaper-browser");
});
- grid_group.add_row(source_row);
+ online_row.set_child(online_button);
+ grid_group.add_row(online_row);
wallpaper_grid = new FlowBox();
wallpaper_grid.add_css_class("wallpaper-gallery");
@@ -236,6 +306,82 @@ namespace Singularity {
rotation_state.get_rotate_enabled());
grid_group.add_row(rotate_row);
+ // Wallpaper attribution overlay toggle. Background.vala listens
+ // for settings.changed["show-wallpaper-attribution"] and hides
+ // the overlay live; the gsettings key also gates the live
+ // wallpaper-changed re-bind so flipping it from off to on redraws
+ // the overlay for the current wallpaper without waiting for the
+ // next rotation cycle.
+ var attribution_row = new SwitchRow(_("Show Wallpaper Info"),
+ _("Display title and photographer credit on the desktop background"),
+ settings.get_boolean("show-wallpaper-attribution"));
+ grid_group.add_row(attribution_row);
+ attribution_row.switch_btn.notify["active"].connect(() => {
+ settings.set_boolean("show-wallpaper-attribution", attribution_row.switch_btn.active);
+ });
+
+ // Bing preferred-region selector. The SelectionRow's expanded
+ // list matches ncz-wallpaper-bing's existing "all" sentinel in
+ // ~/.config/ncz-wallpaper/bing-markets (45-wallpaper-rotator.sh
+ // reads that file verbatim), but the MEANING changed: every
+ // market is always fetched and combined now, so this no
+ // longer restricts what's fetched. It only sets which
+ // region's copy of a duplicate photo the rotator prefers when
+ // de-duplicating.
+ //
+ // Operator 2026-09-13: the previous popup ConfirmDialog picker
+ // was rejected -- the row now expands INLINE, in place, the
+ // same way every other single-choice SelectionRow on this page
+ // works (see e.g. interval_row below). "All Markets, No
+ // Preference" is the first entry and writes "all" immediately
+ // (today's alphabetical dedup-winner order, kept as the
+ // neutral default); every one of the 13 markets from
+ // BING_MARKETS_TABLE follows as its own row, labelled
+ // " — " so the region grouping the old dialog
+ // expressed with section headers survives as label text (and
+ // SelectionRow's own search entry, which kicks in past 5
+ // items, lets a region name filter the list). Picking any row
+ // is a single click: SelectionRow always collapses and fires
+ // `selected` with exactly the one id chosen, so there is no
+ // separate multi-select/Apply step to reproduce. We don't
+ // gate the row on the active wallpaper provider -- the rest of
+ // this page (rotate_row, interval_row, attribution_row) is
+ // also unconditional, and there's no clean existing
+ // provider-detection hook to reuse.
+ init_bing_markets_table();
+ var bing_markets_options = new Gee.ArrayList();
+ bing_markets_options.add(new Singularity.Core.AppSettingOption() { id = BING_MARKETS_ID_ALL, label = _("All Markets, No Preference") });
+ foreach (var market in bing_markets_rows) {
+ bing_markets_options.add(new Singularity.Core.AppSettingOption() {
+ id = market.code, label = "%s — %s".printf(market.region, market.label) });
+ }
+ // Initial selection reflects the file: "all" or absent = All
+ // Markets; otherwise the first configured market code (a
+ // preference is singular -- see bing_markets_read_codes()).
+ // Falls back to "all" if the file names a code that isn't in
+ // the current table, so the row always opens on a real entry.
+ string bing_markets_current = BING_MARKETS_ID_ALL;
+ if (!bing_markets_file_is_all()) {
+ string[] configured = bing_markets_read_codes();
+ if (configured.length > 0) {
+ foreach (var opt in bing_markets_options) {
+ if (opt.id == configured[0]) { bing_markets_current = configured[0]; break; }
+ }
+ }
+ }
+ bing_markets_row = new SelectionRow.with_options(_("Bing Preferred Region"), bing_markets_options,
+ bing_markets_current);
+ bing_markets_row.subtitle = _("Bing always combines every region's photo of the day; this only picks whose caption and credit win when the same photo is shared");
+ bing_markets_row.selected.connect((id) => {
+ if (bing_markets_updating || bing_markets_row == null) return;
+ if (id == BING_MARKETS_ID_ALL) {
+ write_bing_markets_all();
+ } else {
+ write_bing_markets_codes({id});
+ }
+ });
+ grid_group.add_row(bing_markets_row);
+
var interval_options = new Gee.ArrayList();
interval_options.add(new Singularity.Core.AppSettingOption() { id = "600", label = _("Every 10 minutes") });
interval_options.add(new Singularity.Core.AppSettingOption() { id = "1800", label = _("Every 30 minutes") });
@@ -1803,7 +1949,33 @@ namespace Singularity {
}
private void set_wallpaper(string uri) {
- settings.set_string("background-picture-uri", uri);
+ string local_path = "";
+ if (uri != null && uri.length > 0) {
+ var f = GLib.File.new_for_uri(uri);
+ local_path = f.get_path() ?? "";
+ }
+ string title = "";
+ string author = "";
+ if (local_path != "") {
+ var attr = Singularity.WallpaperSidecar.read(local_path);
+ if (attr.valid) {
+ title = attr.title;
+ author = attr.author;
+ }
+ }
+ // Stage all three writes as one atomic dconf transaction -- see
+ // the identical comment on the Reset-to-Default handler above.
+ // Committing background-picture-uri, then the two attribution
+ // keys, as three separate writes let WallpaperManager.reload()
+ // (which listens to all three) run three times in a row, each
+ // with a different partially-updated combination, producing a
+ // visibly flickering/incorrect attribution overlay before it
+ // settled on the right text a couple of dconf round-trips later.
+ settings.delay();
+ SettingsSafety.set_string(settings, "background-picture-uri", uri);
+ SettingsSafety.set_string(settings, "background-attribution-title", title);
+ SettingsSafety.set_string(settings, "background-attribution-author", author);
+ settings.apply();
add_to_recent(uri);
update_preview();
}
@@ -1818,7 +1990,7 @@ namespace Singularity {
new_list += r;
}
}
- settings.set_strv("recent-wallpapers", new_list);
+ SettingsSafety.set_strv(settings, "recent-wallpapers", new_list);
}
private void remove_from_recent(string uri) {
@@ -1829,10 +2001,14 @@ namespace Singularity {
new_list += r;
}
}
- settings.set_strv("recent-wallpapers", new_list);
+ SettingsSafety.set_strv(settings, "recent-wallpapers", new_list);
}
private void update_preview_async() {
+ string current_uri = settings.get_string("background-picture-uri");
+ string? current_path = current_uri != "" ? File.new_for_uri(current_uri).get_path() : null;
+ if (preview_widget != null)
+ preview_widget.set_metadata(WallpaperSidecar.read(current_path ?? ""));
var manager = WallpaperManager.get_default();
if (manager.medium_texture != null && preview_widget != null) {
preview_widget.set_image(manager.medium_texture);
@@ -1917,6 +2093,136 @@ namespace Singularity {
});
}
+ public static string[] compute_collection_roots() {
+ var roots = new Gee.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"));
+ return roots.to_array();
+ }
+
+ public void refresh_after_import() {
+ refresh_wallpaper_sources();
+ populate_grid();
+ }
+
+ private void refresh_wallpaper_sources() {
+ wallpaper_collections = WallpaperCollections.parse(wallpaper_collection_roots);
+ var options = new Gee.ArrayList();
+ foreach (var collection in wallpaper_collections) {
+ string label = (collection.artist != "" && collection.artist != collection.name)
+ ? _("%s — by %s").printf(collection.name, collection.artist) : collection.name;
+ label = "%s — %s".printf(label, collection.theme_pack ? _("Theme pack") : _("Artist pack"));
+ options.add(new Singularity.Core.AppSettingOption() { id = collection.id, label = label });
+ }
+ string selected = rotation_state.get_selected_collection("ncz");
+ bool found = false;
+ foreach (var option in options) if (option.id == selected) found = true;
+ if (!found && options.size > 0) selected = options[0].id;
+ var row = new SelectionRow.with_options(_("Wallpaper Source"), options, selected);
+ row.subtitle = _("Which installed collection the gallery below shows");
+ row.selected.connect((id) => {
+ rotation_state.set_selected_collection(id);
+ refresh_wallpaper_sources();
+ populate_grid();
+ apply_selected_wallpaper.begin();
+ });
+ var old = wallpaper_source_container.get_first_child();
+ if (old != null) wallpaper_source_container.remove(old);
+ var source_box = new Gtk.Box(Orientation.HORIZONTAL, 6);
+ row.hexpand = true;
+ source_box.append(row);
+ var selected_collection = find_collection(selected);
+ if (selected_collection != null && selected_collection.deletable) {
+ var delete_button = new Button.from_icon_name("user-trash-symbolic");
+ delete_button.add_css_class("flat");
+ delete_button.add_css_class("destructive-action");
+ delete_button.tooltip_text = _("Delete wallpaper pack");
+ delete_button.clicked.connect(() => confirm_delete_pack(selected_collection));
+ source_box.append(delete_button);
+ }
+ wallpaper_source_container.append(source_box);
+ }
+
+ private async void apply_selected_wallpaper() {
+ try {
+ var process = new Subprocess.newv(
+ { "/usr/local/bin/ncz-wallpaper-rotate" },
+ SubprocessFlags.STDOUT_SILENCE | SubprocessFlags.STDERR_PIPE);
+ string? stderr_buf = null;
+ yield process.communicate_utf8_async(null, null, null, out stderr_buf);
+ if (!process.get_successful())
+ warning("Could not apply selected wallpaper source: %s",
+ stderr_buf != null ? stderr_buf.strip() : "wallpaper rotator failed");
+ } catch (Error e) {
+ warning("Could not apply selected wallpaper source: %s", e.message);
+ }
+ }
+
+ private WallpaperCollectionInfo? find_collection(string id) {
+ foreach (var collection in wallpaper_collections)
+ if (collection.id == id) return collection;
+ return null;
+ }
+
+ // The grid built by populate_grid() is NOT limited to the active
+ // rotation source's own directory -- WallpaperGallery.scan() is
+ // given every known collection's dir (collection_dirs) alongside
+ // scan_dir, so a card's uri can belong to a collection OTHER than
+ // whichever one is currently selected as the rotation source (e.g.
+ // a "recent" wallpaper carried over from a previously-active pack).
+ // add_wallpaper_card() used to resolve the delete target via
+ // find_collection(rotation_state.get_selected_collection("ncz")),
+ // which is always the ACTIVE source, not necessarily the collection
+ // that actually contains this specific uri. For any card whose
+ // image lives in a different collection, that mismatch made
+ // WallpaperCollections.delete_image()'s contains_uri() check fail,
+ // throwing IOError.PERMISSION_DENIED -- caught by confirm_delete_
+ // image()'s catch block, which only logs a warning(), so the click
+ // silently did nothing from the user's perspective. Resolve the
+ // REAL owning collection by uri instead of assuming it's whatever
+ // is currently selected.
+ private WallpaperCollectionInfo? find_owning_collection(string uri) {
+ foreach (var collection in wallpaper_collections)
+ if (collection.contains_uri(uri)) return collection;
+ return null;
+ }
+
+ private void confirm_delete_pack(WallpaperCollectionInfo collection) {
+ var app = GLib.Application.get_default() as Gtk.Application;
+ var dialog = new ConfirmDialog(app,
+ _("Delete “%s”?").printf(collection.name), "user-trash-symbolic",
+ _("This permanently deletes every photo in this wallpaper pack."),
+ _("Delete Pack"), ConfirmDialog.ActionStyle.DESTRUCTIVE);
+ dialog.response.connect((r) => {
+ if (r == ConfirmDialog.Response.PRIMARY) delete_pack(collection);
+ });
+ dialog.present();
+ }
+
+ private void reset_deleted_background(bool was_active) {
+ if (!was_active) return;
+ settings.delay();
+ settings.reset("background-picture-uri");
+ SettingsSafety.set_string(settings, "background-attribution-title", "");
+ SettingsSafety.set_string(settings, "background-attribution-author", "");
+ settings.apply();
+ }
+
+ private void delete_pack(WallpaperCollectionInfo collection) {
+ bool was_active = WallpaperCollections.needs_background_fallback(
+ collection, settings.get_string("background-picture-uri"));
+ try {
+ WallpaperCollections.delete_pack(collection);
+ reset_deleted_background(was_active);
+ refresh_wallpaper_sources();
+ populate_grid();
+ } catch (Error e) {
+ warning("Could not delete wallpaper pack %s: %s", collection.id, e.message);
+ }
+ }
+
private void populate_grid() {
int gen = ++wallpaper_grid_generation;
wallpaper_grid.remove_all();
@@ -1978,15 +2284,44 @@ namespace Singularity {
}
private void add_wallpaper_card(string uri, bool is_recent) {
- var card = new WallpaperCard(uri, is_recent);
+ var collection = find_owning_collection(uri);
+ bool can_delete = collection != null && collection.deletable;
+ var card = new WallpaperCard(uri, is_recent, can_delete);
card.set_selected(uri == settings.get_string("background-picture-uri"));
card.clicked.connect(() => set_wallpaper(uri));
- if (is_recent) {
+ if (can_delete) {
+ card.delete_clicked.connect(() => confirm_delete_image(collection, uri));
+ } else if (is_recent) {
card.delete_clicked.connect(() => remove_from_recent(uri));
}
wallpaper_grid.append(card);
}
+ private void confirm_delete_image(WallpaperCollectionInfo collection, string uri) {
+ string name = File.new_for_uri(uri).get_basename() ?? _("this photo");
+ var app = GLib.Application.get_default() as Gtk.Application;
+ var dialog = new ConfirmDialog(app,
+ _("Delete “%s”?").printf(name), "user-trash-symbolic",
+ _("This photo will be permanently deleted."),
+ _("Delete Photo"), ConfirmDialog.ActionStyle.DESTRUCTIVE);
+ dialog.response.connect((r) => {
+ if (r != ConfirmDialog.Response.PRIMARY) return;
+ bool was_active = WallpaperCollections.needs_background_fallback(
+ collection, settings.get_string("background-picture-uri")) &&
+ uri == settings.get_string("background-picture-uri");
+ try {
+ bool pack_deleted = WallpaperCollections.delete_image(collection, uri);
+ remove_from_recent(uri);
+ reset_deleted_background(was_active);
+ if (pack_deleted) refresh_wallpaper_sources();
+ populate_grid();
+ } catch (Error e) {
+ warning("Could not delete wallpaper %s: %s", uri, e.message);
+ }
+ });
+ dialog.present();
+ }
+
// Color picker helpers
private static void picker_rgb_to_hsv(double r, double g, double b,
@@ -2303,10 +2638,160 @@ namespace Singularity {
warning("eyedropper failed: %s", e.message);
}
}
+
+ // -------------------------------------------------------------------------
+ // Bing markets selector. UI side of cix-installer's
+ // 45-wallpaper-rotator.sh's ncz-wallpaper-bing contract.
+ // -------------------------------------------------------------------------
+
+ // Parse BING_MARKETS_TABLE into bing_markets_rows ({code, label,
+ // region}), in table order. Called once from the constructor,
+ // before the flat SelectionRow option list is built from it.
+ private void init_bing_markets_table() {
+ foreach (string entry in BING_MARKETS_TABLE.split("|")) {
+ string[] cols = entry.split("\t");
+ if (cols.length != 3) continue;
+ var row = new BingMarketEntry() { code = cols[0], label = cols[1], region = cols[2] };
+ bing_markets_rows.add(row);
+ }
+ }
+
+ // Full path to the bing-markets file the cix-installer rotator
+ // already reads. Lives under XDG_CONFIG_HOME so it tracks the
+ // user even when $HOME is relocated for test sessions.
+ private string bing_markets_file_path() {
+ return GLib.Path.build_filename(
+ GLib.Environment.get_user_config_dir(),
+ "ncz-wallpaper",
+ "bing-markets");
+ }
+
+ // Read the bing-markets file and report whether its content
+ // (trimmed, lowercased) is the "all" sentinel -- i.e. "no
+ // preferred region". Absent file also returns true so the
+ // SelectionRow starts on "All Markets, No Preference" on a fresh
+ // install, matching the rotator's own default (preferred_market()
+ // in 45-wallpaper-rotator.sh returns None for an absent file too
+ // -- UI intent matches effective behaviour on both sides now).
+ private bool bing_markets_file_is_all() {
+ string path = bing_markets_file_path();
+ if (!FileUtils.test(path, FileTest.EXISTS)) return true;
+ string text;
+ try {
+ FileUtils.get_contents(path, out text);
+ } catch (Error e) {
+ return true;
+ }
+ return ascii_lower(text.strip()) == "all";
+ }
+
+ // Read the bing-markets file and return the configured codes as
+ // an array. "all" (any case) or absent -> empty list (i.e. the
+ // sentinel meaning "no preferred region"). Otherwise split on any
+ // of whitespace/comma and keep tokens matching the 2-letter-2-
+ // letter market pattern, preserving file order. The rotator only
+ // ever honours the FIRST entry as the preference (a preference is
+ // singular); this still returns every matched token so a legacy
+ // multi-market file written by an older build degrades to "the
+ // first one wins" rather than silently losing the whole value.
+ private string[] bing_markets_read_codes() {
+ string path = bing_markets_file_path();
+ if (!FileUtils.test(path, FileTest.EXISTS)) return {};
+ string text;
+ try {
+ FileUtils.get_contents(path, out text);
+ } catch (Error e) {
+ return {};
+ }
+ if (ascii_lower(text.strip()) == "all") return {};
+ string[] codes = {};
+ string[] seen = {};
+ foreach (string tok in text.strip().split_set(" \t\n,")) {
+ if (tok.length == 0) continue;
+ if (tok.length != 5 || tok[2] != '-') continue;
+ bool dup = false;
+ foreach (string existing in seen) if (existing == tok) { dup = true; break; }
+ if (dup) continue;
+ seen += tok;
+ codes += tok;
+ }
+ return codes;
+ }
+
+ // Atomic write of a single-line contents string to the
+ // bing-markets file. Same write-then-rename pattern as
+ // WallpaperRotationState so the daemon (which polls the file)
+ // never reads a half-flushed value. Creates the directory if
+ // absent. Silent on failure -- the daemon's default kicks in if
+ // the file is missing, so a failed write degrades gracefully.
+ private void write_bing_markets_contents(string contents) {
+ string path = bing_markets_file_path();
+ string dir = GLib.Path.get_dirname(path);
+ try {
+ GLib.DirUtils.create_with_parents(dir, 0700);
+ string tmp = path + ".tmp";
+ FileUtils.set_contents(tmp, contents);
+ if (FileUtils.rename(tmp, path) != 0) {
+ warning("bing markets: could not rename %s into place", path);
+ }
+ } catch (Error e) {
+ warning("bing markets: could not write %s: %s", path, e.message);
+ }
+ }
+
+ private void write_bing_markets_all() {
+ write_bing_markets_contents("all\n");
+ }
+
+ // Write the chosen preferred market as a single line
+ // (newline-terminated, matching the format the rotator already
+ // expects -- it only ever honours the FIRST valid token in the
+ // file now; see preferred_market() in 45-wallpaper-rotator.sh).
+ // Empty list -> fall back to "all" rather than an empty file,
+ // because ncz-wallpaper-bing treats an empty value as "no
+ // preference" anyway, and an empty file would be picked up by
+ // the rotator's split() as a literal empty list with no
+ // behaviour change -- but writing "all" makes the user's "no
+ // preferred region" intent explicit on disk.
+ private void write_bing_markets_codes(string[] codes) {
+ if (codes.length == 0) {
+ write_bing_markets_all();
+ return;
+ }
+ write_bing_markets_contents(string.joinv(" ", codes) + "\n");
+ }
+
+ // The preferred-region picker used to be a separate popup
+ // ConfirmDialog built here, with its own reused dialog object and
+ // one mutually-exclusive Gtk.CheckButton per market grouped under
+ // a region header (see git history before 2026-09-13 for the
+ // removed implementation). Operator 2026-09-13 rejected the
+ // popup in favor of expanding inline in the settings row itself
+ // -- bing_markets_row (constructed above) is a plain
+ // SelectionRow.with_options() whose option list already contains
+ // "All Markets, No Preference" plus all 13 markets, so picking a
+ // region is just clicking a row in the row's own expander; there
+ // is no dialog, no checkbox list, and no separate Apply step left
+ // to implement here.
+
+ // Bing market row: 2-letter market code, UI display label, and
+ // region bucket ("Americas" / "Europe" / "Asia-Pacific") used
+ // as the label prefix in the inline SelectionRow's option list
+ // above. Plain GLib.Object rather than a struct so it can be
+ // stored in a Gee.ArrayList (Vala disallows array types as
+ // generic type arguments).
+ private class BingMarketEntry : GLib.Object {
+ public string code { get; set; }
+ public string label { get; set; }
+ public string region { get; set; }
+ }
}
internal class WallpaperPreviewWidget : Box {
public signal void select_clicked();
private Picture preview_picture;
+ private Label metadata;
+ private LinkButton source_link;
+ private LinkButton license_link;
public WallpaperPreviewWidget() {
Object(orientation: Orientation.VERTICAL, spacing: 0);
@@ -2320,6 +2805,20 @@ namespace Singularity {
preview_picture.can_shrink = true;
image_area.append(preview_picture);
append(image_area);
+ metadata = new Label("");
+ metadata.use_markup = false;
+ metadata.wrap = true;
+ metadata.selectable = true;
+ metadata.max_width_chars = 40;
+ metadata.margin_start = metadata.margin_end = 12;
+ metadata.margin_top = metadata.margin_bottom = 8;
+ metadata.visible = false;
+ append(metadata);
+ source_link = new LinkButton.with_label("", _("Original image / attribution"));
+ license_link = new LinkButton.with_label("", _("Image license"));
+ source_link.visible = license_link.visible = false;
+ append(source_link);
+ append(license_link);
var sep = new Separator(Orientation.HORIZONTAL);
append(sep);
var btn = new Button.with_label(_("Select Picture..."));
@@ -2334,20 +2833,119 @@ namespace Singularity {
public void set_image(Gdk.Paintable paintable) {
preview_picture.set_paintable(paintable);
}
+
+ public void set_metadata(WallpaperAttribution attribution) {
+ metadata.label = WallpaperSidecar.display_text(attribution);
+ metadata.visible = metadata.label != "";
+ source_link.uri = attribution.page_url;
+ license_link.uri = attribution.license_url;
+ source_link.visible = attribution.page_url.has_prefix("https://") || attribution.page_url.has_prefix("http://");
+ license_link.visible = attribution.license_url.has_prefix("https://") || attribution.license_url.has_prefix("http://");
+ }
}
+ // Visual parity with the main Desktop wallpaper picker. Both the local
+ // wallpaper picker (desktop_page) and the OCS/Bing browser reuse this
+ // widget so the thumbnail grid LOOKs identical regardless of source.
+ // Two construction paths exist:
+ // * WallpaperCard(uri, is_recent) -- local-file thumbnail,
+ // title from basename.
+ // * WallpaperCard.for_remote(uri, title,
+ // is_recent, loader)
+ // -- caller-supplied async
+ // thumbnail loader (used
+ // by OCS Soup and Bing
+ // local-file loads); an
+ // explicit title string
+ // (not basename).
+ // Both paths produce the same chrome: 172x104 clipped rounded frame,
+ // Picture with ContentFit.COVER, title overlay with object-select check,
+ // and the same wallpaper-card / workspace-preview CSS classes.
internal class WallpaperCard : Box {
public signal void clicked();
public signal void delete_clicked();
public string uri { get; private set; }
private Picture picture;
+ // The overlay that hosts the picture, title, badges, and recents action.
+ private Overlay card_overlay;
private string thumb_path;
+ // Optional remote-thumbnail loader set by WallpaperCard.for_remote().
+ // If non-null, replaces the local-file path; runs on a worker thread
+ // bounded by thumb_mutex (same cap as the local loader).
+ // Public so for_remote()'s parameter list is well-typed -- a public
+ // method cannot take a private delegate parameter without an
+ // accessibility error from valac.
+ public delegate Gdk.Pixbuf? RemoteThumbnailLoader() throws Error;
+ private RemoteThumbnailLoader? remote_loader;
private static Mutex thumb_mutex = Mutex();
private static Cond thumb_cond = Cond();
private static int active_thumb_loads = 0;
- public WallpaperCard(string uri, bool is_recent) {
+ public WallpaperCard(string uri, bool is_recent, bool can_delete = false) {
Object(orientation: Orientation.VERTICAL, spacing: 0);
this.uri = uri;
+ var file = File.new_for_uri(uri);
+ string title = file.get_basename() ?? _("Wallpaper");
+ // If the URI is a local path, the existing loader handles it.
+ // For remote URIs (no path), the local loader would just no-op
+ // since thumb_path is "" -- call sites that need a remote
+ // thumbnail MUST use WallpaperCard.for_remote() instead.
+ thumb_path = file.get_path() ?? "";
+ // The recents-only trash button lives in this constructor only;
+ // for_remote() / placeholder_only() never carry a delete
+ // affordance.
+ Button? del_btn = null;
+ if (is_recent || can_delete) {
+ del_btn = new Button.from_icon_name("user-trash-symbolic");
+ // flat+osd keeps the recents action legible over the image.
+ del_btn.add_css_class("flat");
+ del_btn.add_css_class("osd");
+ del_btn.valign = Align.START;
+ del_btn.halign = Align.END;
+ del_btn.margin_top = 4;
+ del_btn.margin_end = 4;
+ del_btn.clicked.connect(() => delete_clicked());
+ }
+ build_card(title, del_btn);
+ if (thumb_path != "") load_thumbnail_async();
+ }
+
+ // Remote-thumbnail variant. `loader` runs on a worker thread (same
+ // concurrency cap as the local file loader) and must return a
+ // decoded Pixbuf or throw; throws are swallowed silently the same
+ // way local-file failures are, leaving the placeholder visible.
+ // `is_recent` is accepted for signature symmetry with the local
+ // constructor but is unused (remote sources never carry the
+ // recents-trash affordance).
+ public WallpaperCard.for_remote(string uri, string title, bool is_recent, owned RemoteThumbnailLoader loader) {
+ Object(orientation: Orientation.VERTICAL, spacing: 0);
+ this.uri = uri;
+ thumb_path = "";
+ remote_loader = (owned) loader;
+ build_card(title, null);
+ if (remote_loader != null) load_remote_thumbnail_async();
+ }
+
+ // Placeholder-only variant. Builds the same chrome as for_remote
+ // but does NOT start any worker thread for thumbnail loading --
+ // the caller takes full responsibility for calling set_paintable()
+ // when the thumbnail is ready. Used by the OCS/Bing browser, which
+ // needs generation-aware / cancellable thumbnail loads that the
+ // built-in loader does not provide.
+ public WallpaperCard.placeholder_only(string uri, string title) {
+ Object(orientation: Orientation.VERTICAL, spacing: 0);
+ this.uri = uri;
+ thumb_path = "";
+ build_card(title, null);
+ }
+
+ // Shared chrome assembly. Both constructors funnel through here so
+ // visual parity is guaranteed (same ScrolledWindow clipper, same
+ // Picture, same title overlay, same checkmark, same CSS classes).
+ // The chain-init Object() call happens in each constructor -- not
+ // here -- because Vala only allows Object() in a constructor
+ // context. `del_btn` is the recents-only trash button, built by
+ // the caller; pass null otherwise.
+ private void build_card(string title, Button? del_btn) {
add_css_class("wallpaper-card");
add_css_class("workspace-preview");
halign = Align.CENTER;
@@ -2362,29 +2960,14 @@ namespace Singularity {
clipper.hscrollbar_policy = PolicyType.NEVER;
clipper.vscrollbar_policy = PolicyType.NEVER;
clipper.has_frame = false;
- var overlay = new Overlay();
- clipper.set_child(overlay);
+ card_overlay = new Overlay();
+ clipper.set_child(card_overlay);
picture = new Picture();
picture.add_css_class("wallpaper-card-picture");
picture.content_fit = ContentFit.COVER;
picture.can_shrink = true;
- overlay.set_child(picture);
- var file = File.new_for_uri(uri);
- thumb_path = file.get_path() ?? "";
- if (thumb_path != "") {
- load_thumbnail_async();
- }
- if (is_recent) {
- var del_btn = new Button.from_icon_name("user-trash-symbolic");
- del_btn.add_css_class("flat");
- del_btn.add_css_class("osd");
- del_btn.valign = Align.START;
- del_btn.halign = Align.END;
- del_btn.margin_top = 4;
- del_btn.margin_end = 4;
- del_btn.clicked.connect(() => delete_clicked());
- overlay.add_overlay(del_btn);
- }
+ card_overlay.set_child(picture);
+ if (del_btn != null) card_overlay.add_overlay(del_btn);
var title_box = new Box(Orientation.HORIZONTAL, 6);
title_box.add_css_class("wallpaper-card-title");
title_box.valign = Align.END;
@@ -2393,27 +2976,81 @@ namespace Singularity {
title_box.margin_start = 8;
title_box.margin_end = 8;
title_box.margin_bottom = 8;
- var title = new Label(file.get_basename() ?? _("Wallpaper"));
- title.ellipsize = Pango.EllipsizeMode.END;
- title.xalign = 0;
- title.hexpand = true;
- title_box.append(title);
+ var title_label = new Label(title);
+ title_label.ellipsize = Pango.EllipsizeMode.END;
+ title_label.xalign = 0;
+ title_label.hexpand = true;
+ title_box.append(title_label);
var check = new Image.from_icon_name("object-select-symbolic");
check.add_css_class("wallpaper-card-check");
check.pixel_size = 14;
title_box.append(check);
- overlay.add_overlay(title_box);
+ card_overlay.add_overlay(title_box);
append(clipper);
var click_ctrl = new GestureClick();
click_ctrl.pressed.connect(() => clicked());
add_controller(click_ctrl);
}
+ // Place a full-width action below the thumbnail, matching the
+ // wallpaper preview's image/separator/button convention.
+ public void append_action_button(Button btn) {
+ var sep = new Separator(Orientation.HORIZONTAL);
+ btn.add_css_class("flat");
+ btn.hexpand = true;
+ btn.height_request = 36;
+ append(sep);
+ append(btn);
+ }
+
+ // Adds a small attribution/licence label above the title bar so
+ // OCS/Bing items can show uploader + licence without inflating
+ // the card height. Null/empty clears any previous badge. Reuses
+ // the wallpaper-card-title styling so it reads as part of the
+ // existing title overlay rather than a new ad-hoc element.
+ public void set_badge(string? text) {
+ // Strip any previous badge: tracked by the data key so we
+ // never collide with user code that happens to set the same
+ // key for something else.
+ Widget? prev = get_data("singularity-wallpaper-badge");
+ if (prev != null) {
+ card_overlay.remove_overlay(prev);
+ set_data("singularity-wallpaper-badge", null);
+ }
+ if (text == null || text == "") return;
+ var badge = new Label(text);
+ badge.add_css_class("wallpaper-card-title");
+ badge.ellipsize = Pango.EllipsizeMode.END;
+ badge.xalign = 0;
+ badge.max_width_chars = 22;
+ badge.halign = Align.START;
+ badge.valign = Align.START;
+ badge.margin_start = 8;
+ badge.margin_top = 6;
+ card_overlay.add_overlay(badge);
+ set_data("singularity-wallpaper-badge", badge);
+ }
+
public void set_selected(bool selected) {
if (selected) add_css_class("selected");
else remove_css_class("selected");
}
+ // Public so call sites (e.g. OCS/Bing browser) can paint a thumbnail
+ // they fetched themselves, bypassing the local-file or remote-loader
+ // path entirely. Safe to call multiple times; replaces any prior
+ // paintable on the picture.
+ public void set_paintable(Gdk.Paintable? paintable) {
+ picture.set_paintable(paintable);
+ }
+
+ // Exposed for tests/diagnostics; lets a caller ask whether a
+ // thumbnail is currently displayed (true once set_paintable has
+ // received a non-null value, regardless of source).
+ public bool has_thumbnail {
+ get { return picture.get_paintable() != null; }
+ }
+
private void load_thumbnail_async() {
new GLib.Thread("wallpaper-thumb", () => {
Gdk.Pixbuf? pb = null;
@@ -2440,5 +3077,38 @@ namespace Singularity {
});
});
}
+
+ // Remote-thumbnail worker. Same concurrency cap as the local-file
+ // loader so a flood of remote loads does not starve other paths.
+ // Loader exceptions are swallowed silently (matching the local-file
+ // loader's behaviour) and the placeholder stays visible.
+ private void load_remote_thumbnail_async() {
+ new GLib.Thread("wallpaper-thumb-remote", () => {
+ Gdk.Pixbuf? pb = null;
+ thumb_mutex.lock();
+ while (active_thumb_loads >= 3) {
+ thumb_cond.wait(thumb_mutex);
+ }
+ active_thumb_loads++;
+ thumb_mutex.unlock();
+
+ if (remote_loader != null) {
+ try {
+ pb = remote_loader();
+ } catch (Error e) {}
+ }
+
+ thumb_mutex.lock();
+ active_thumb_loads--;
+ thumb_cond.signal();
+ thumb_mutex.unlock();
+
+ GLib.Idle.add(() => {
+ if (pb != null)
+ picture.set_paintable(Gdk.Texture.for_pixbuf(pb));
+ return GLib.Source.REMOVE;
+ });
+ });
+ }
}
}
diff --git a/src/components/sidebar/pages/provider_credential_group.vala b/src/components/sidebar/pages/provider_credential_group.vala
new file mode 100644
index 0000000..8944ddc
--- /dev/null
+++ b/src/components/sidebar/pages/provider_credential_group.vala
@@ -0,0 +1,38 @@
+using Gtk;
+using Singularity.Widgets;
+
+namespace Singularity.Shell {
+ // Shared credential UI: providers choose an email or a secret-key row
+ // and handle submission without putting credentials in command arguments.
+ public class ProviderCredentialGroup : PreferencesGroup {
+ public signal void submitted(string value);
+ private EntryRow entry;
+ private Button submit;
+ private ActionRow state;
+
+ public ProviderCredentialGroup(string provider, string prompt, bool secret, string explanation) {
+ title = provider;
+ description = explanation;
+ entry = secret ? new PasswordRow(prompt) : new EntryRow(prompt);
+ submit = new Button.with_label(_("Submit"));
+ submit.valign = Align.CENTER;
+ submit.clicked.connect(() => {
+ string value = entry.text.strip();
+ if (value != "") submitted(value);
+ });
+ entry.add_suffix(submit);
+ add_row(entry);
+ state = new ActionRow("");
+ state.visible = false;
+ add_row(state);
+ }
+
+ public void set_state(string message, bool can_submit) {
+ state.title = message;
+ state.visible = message != "";
+ entry.sensitive = can_submit;
+ submit.sensitive = can_submit;
+ if (!can_submit) entry.text = "";
+ }
+ }
+}
diff --git a/src/components/sidebar/pages/wallpaper_ocs_browser.vala b/src/components/sidebar/pages/wallpaper_ocs_browser.vala
new file mode 100644
index 0000000..17f7e43
--- /dev/null
+++ b/src/components/sidebar/pages/wallpaper_ocs_browser.vala
@@ -0,0 +1,1282 @@
+using Gtk;
+using Gee;
+using Singularity.Widgets;
+
+namespace Singularity.Shell {
+ // Presentation only: the installed helper owns all OCS and import policy.
+ public class WallpaperOcsBrowserPage : SettingsPage {
+ public signal void imported();
+ private const string HELPER = "/usr/local/bin/ncz-wallpaper-ocs";
+ // Bing lives behind its own helper because its commands and JSON
+ // shapes are different (markets -> TSV, list -> bare array, no
+ // schema/items wrapper). Calling it is the same SubprocessLauncher
+ // shape as HELPER; only the argv and the parsers in WallpaperBing
+ // differ. The helper's daily timer permanently accumulates unseen
+ // images in bing.collection; browsing only reads that local archive.
+ private const string BING_HELPER = "/usr/local/bin/ncz-wallpaper-bing";
+ private const string OPENVERSE_HELPER = "/usr/local/bin/ncz-wallpaper-openverse";
+ private const string UNSPLASH_HELPER = "/usr/local/bin/ncz-wallpaper-unsplash";
+ private WallpaperProviderRegistry provider_registry = new WallpaperProviderRegistry();
+ private ProviderCredentialGroup openverse_credentials;
+ private ProviderCredentialGroup unsplash_credentials;
+ private PreferencesGroup online_search_group;
+ private EntryRow online_search;
+ private Button previous_page;
+ private Button next_page;
+ private int photo_page = 1;
+ private int photo_page_count = 1;
+ private bool force_refresh = false;
+ // The synthetic provider id used by the provider dropdown, the worker
+ // branching, and the card layout. Same value as WallpaperBing.PROVIDER_ID
+ // in core/ -- duplicated here so the browser can branch on it
+ // without pulling in a core class field reference at the call site.
+ private const string BING_PROVIDER_ID = "bing";
+ // Bounded crawl: enough parallelism that a category with many pages
+ // fills the grid quickly, but small enough it cannot fork dozens of
+ // OCS processes against the helper at once. A crawl now covers a
+ // single, user-picked category (see browse_category()), not every
+ // category a provider exposes, so CRAWL_WORKERS is a ceiling on
+ // in-flight requests for that one category rather than a fan-out
+ // across many categories.
+ private const int CRAWL_WORKERS = 4;
+ // Safety cap on wallpapers merged for one category. A single OCS
+ // category page tops out at the server's maximum 100 items/page (see
+ // OcsWallpaperProvider); this cap guards against an unexpectedly
+ // large category rather than truncating a normal one.
+ private const int CRAWL_ITEM_CAP = 4000;
+ private const int THUMBNAIL_FETCH_LANES = 8;
+ // Building one WallpaperCard hierarchy (badge, action button, FlowBox
+ // append) measured ~0.6ms/card on CIX Sky1 target hardware. Building
+ // a full batch synchronously in load_cached()/revalidate_cached()
+ // would block the main thread for its whole duration, so cards are
+ // built in bounded batches one main-loop turn apart -- each batch
+ // stays under a perceptible-freeze threshold and the grid visibly
+ // fills in instead of the shell appearing to hang.
+ private const int CARD_BUILD_BATCH_SIZE = 150;
+ // One extra screenful keeps the next rows ready without decoding the
+ // thousands of FlowBox children that have never approached view.
+ private const int VIEWPORT_PREFETCH_MARGIN_PX = 400;
+ // Keep several screenfuls behind/ahead as hysteresis so small scrolls
+ // do not repeatedly discard and decode thumbnails at the load edge.
+ private const int VIEWPORT_EVICT_MARGIN_PX = 1600;
+ // Per-category subprocess timeout, matches the previous single-call
+ // bound so a slow category can't drag a worker beyond the overall
+ // window the user is willing to wait.
+ private const int CRAWL_CATEGORY_TIMEOUT = 60;
+ private string[] collection_roots;
+ private WallpaperOcsImports imports = new WallpaperOcsImports();
+ private ArrayList providers = new ArrayList();
+ private ArrayList categories = new ArrayList();
+ private ArrayList cards = new ArrayList();
+ private HashSet thumbnail_requested = new HashSet();
+ private HashSet thumbnail_pending = new HashSet();
+ // Category is now the only filter axis (tag filtering removed), and
+ // it drives WHAT gets loaded rather than filtering an already-loaded
+ // aggregate -- see browse_category(). An empty id means "nothing
+ // picked yet", not "show everything".
+ private string active_category_id = "";
+ private PreferencesGroup provider_group;
+ private SelectionRow provider_row;
+ private SelectionRow category_row;
+ private EntryRow? search_row;
+ private Button refresh;
+ private Spinner spinner;
+ private Label status;
+ private FlowBox grid;
+ private Soup.Session session = new Soup.Session();
+ private WallpaperThumbnailCache thumbnail_cache = new WallpaperThumbnailCache();
+ private Cancellable request = new Cancellable();
+ private int generation = 0;
+ private bool loading = false;
+ private string category_index = "";
+
+ private enum CacheLoadResult { NONE, FRESH, STALE }
+
+ // One card per wallpapers item in the grid. The visible widget is
+ // a WallpaperCard (reused from desktop_page.vala so the OCS/Bing
+ // grid LOOKS identical to the main wallpaper picker). The action button
+ // sits below the thumbnail; attribution and licence text use the card
+ // badge. Status text
+ // for long-running ops (Import / Pin) goes to the global status
+ // label rather than a per-card inline message, since WallpaperCard
+ // has no room for one.
+ private class OcsCard : Object {
+ public WallpaperItem item;
+ public WallpaperCard card;
+ public Button button;
+ public bool matches = true;
+ }
+
+ // SettingsView caches pages and reuses this instance across every
+ // visit (settings_view.vala: "Reuse cached pages - they self-update
+ // via GSettings listeners"). imports.discover() only scans sidecars
+ // that exist on disk AT THE TIME IT RUNS, so a one-time call in the
+ // constructor goes stale the moment a collection is deleted+
+ // re-imported from elsewhere (e.g. the Desktop settings page) while
+ // this page sits cached: the in-memory "added" set still claims the
+ // re-imported keys are present, so their cards render greyed out
+ // ("Added", disabled) even though the files backing that claim are
+ // long gone. Re-run discover() (and re-browse so the grid's cards are
+ // rebuilt with fresh is_added() state baked into both their label and
+ // sensitivity) every time this page becomes visible again, not just
+ // once at construction.
+ //
+ // That re-browse is cache-aware: a crawl younger than
+ // WallpaperBrowseCache.TTL_SECONDS repaints the grid from disk instead
+ // of re-crawling the category over the network, which is what makes
+ // a repeat visit instant. It deliberately does NOT set force_refresh:
+ // returning to a page the user has already seen is not a request for
+ // fresher data, it is a request to see the page again -- and, if a
+ // category is already selected, a request to see it refreshed (see
+ // browse_all()'s dispatch). The Refresh button is the explicit way
+ // to bypass the cache.
+ private bool mapped_once = false;
+
+ public WallpaperOcsBrowserPage(SettingsView view, string[] roots) {
+ base(_("Online Wallpapers"));
+ collection_roots = roots;
+ imports.discover(WallpaperCollections.parse(roots));
+ session.timeout = 25;
+ session.user_agent = "Singularity-Wallpaper-Browser/1";
+ this.map.connect(() => {
+ if (!mapped_once) { mapped_once = true; return; }
+ imports.discover(WallpaperCollections.parse(collection_roots));
+ browse_all.begin();
+ });
+ back_clicked.connect(() => view.navigate_to("desktop"));
+
+ provider_group = new PreferencesGroup();
+ provider_row = new SelectionRow.with_options(_("Online source"),
+ new Gee.ArrayList());
+ provider_group.add_row(provider_row);
+ add_group(provider_group);
+
+ openverse_credentials = new ProviderCredentialGroup(_("Openverse account"), _("Your email address"), false,
+ _("Optional per-user registration. Openverse sends a verification email; until verified, anonymous-tier limits apply. Credentials stay on this computer."));
+ openverse_credentials.submitted.connect((value) => register_openverse.begin(value));
+ add_group(openverse_credentials);
+ unsplash_credentials = new ProviderCredentialGroup(_("Unsplash account"), _("Your Unsplash Access Key"), true,
+ _("Optional personal key. Without one, Stock Photos still searches Openverse. The key stays on this computer."));
+ unsplash_credentials.submitted.connect((value) => configure_unsplash.begin(value));
+ add_group(unsplash_credentials);
+ online_search_group = new PreferencesGroup();
+ online_search = new EntryRow(_("Search Stock Photos"));
+ online_search.text = "nature";
+ // EntryRow has no built-in show_apply_button/apply pair; an
+ // explicit suffix button plus Enter-to-search covers the same
+ // interaction.
+ var online_search_apply = new Button.from_icon_name("object-select-symbolic");
+ online_search_apply.tooltip_text = _("Search");
+ online_search_apply.valign = Align.CENTER;
+ online_search_apply.add_css_class("flat");
+ online_search_apply.clicked.connect(() => { photo_page = 1; browse_all.begin(); });
+ online_search.add_suffix(online_search_apply);
+ online_search.entry_activated.connect(() => { photo_page = 1; browse_all.begin(); });
+ online_search_group.add_row(online_search);
+ var pagination = new ActionRow(_("Search results"));
+ previous_page = new Button.with_label(_("Previous"));
+ next_page = new Button.with_label(_("Next"));
+ previous_page.valign = next_page.valign = Align.CENTER;
+ previous_page.clicked.connect(() => { photo_page--; browse_all.begin(); });
+ next_page.clicked.connect(() => { photo_page++; browse_all.begin(); });
+ pagination.add_suffix(previous_page);
+ pagination.add_suffix(next_page);
+ online_search_group.add_row(pagination);
+ add_group(online_search_group);
+
+ var search_group = new PreferencesGroup();
+ search_row = new EntryRow(_("Filter loaded wallpapers"));
+ search_row.entry_changed.connect(() => {
+ if (!updating) filter_cards();
+ });
+ refresh = new Button.from_icon_name("view-refresh-symbolic");
+ // force_refresh does two things: it bypasses the on-disk crawl
+ // cache in browse_category(), and it sets NCZ_WALLPAPER_REFRESH
+ // for the helper so its own cache is bypassed too. Refresh is
+ // therefore the one path that is guaranteed to hit the network.
+ refresh.tooltip_text = _("Refresh now (ignore cached results)");
+ refresh.valign = Align.CENTER;
+ refresh.clicked.connect(() => {
+ force_refresh = true;
+ browse_all.begin();
+ });
+ search_row.add_suffix(refresh);
+ search_group.add_row(search_row);
+ add_group(search_group);
+
+ var category_group = new PreferencesGroup();
+ category_row = new SelectionRow.with_options(_("Category"),
+ new Gee.ArrayList());
+ category_group.add_row(category_row);
+ add_group(category_group);
+
+ var results_group = new PreferencesGroup();
+ var progress_row = new PreferencesRow();
+ progress_row.activatable = false;
+ var progress = new Box(Orientation.HORIZONTAL, 8);
+ progress.margin_start = progress.margin_end = 8;
+ progress.margin_top = progress.margin_bottom = 6;
+ spinner = new Spinner();
+ spinner.valign = Align.CENTER;
+ progress.append(spinner);
+ status = new Label("");
+ status.wrap = true;
+ status.xalign = 0;
+ status.hexpand = true;
+ progress.append(status);
+ progress_row.set_child(progress);
+ results_group.add_row(progress_row);
+ grid = new FlowBox();
+ grid.add_css_class("wallpaper-gallery");
+ grid.valign = Align.START;
+ grid.halign = Align.FILL;
+ grid.hexpand = true;
+ grid.max_children_per_line = 2;
+ grid.min_children_per_line = 2;
+ grid.selection_mode = SelectionMode.NONE;
+ // Let FlowBox remove non-matches from layout. Merely hiding the
+ // card widget leaves its FlowBoxChild allocated and produces the
+ // large empty slots seen with narrow filters such as "4K".
+ grid.set_filter_func(filter_grid_child);
+ grid.column_spacing = 14;
+ grid.row_spacing = 14;
+ grid.margin_top = grid.margin_bottom = 10;
+ grid.margin_start = grid.margin_end = 10;
+ scroller.vadjustment.value_changed.connect(queue_viewport_thumbnails);
+ var grid_row = new PreferencesRow();
+ grid_row.activatable = false;
+ grid_row.set_child(grid);
+ results_group.add_row(grid_row);
+ add_group(results_group);
+
+ provider_row.selected.connect((id) => {
+ if (!updating) select_provider(id);
+ });
+ category_row.selected.connect((id) => {
+ if (!updating) on_category_row_selected(id);
+ });
+ initialize.begin();
+ }
+
+ // SelectionRow's `selected` signal fires only from a user click on
+ // an expanded option (set_options()/current_value assignment during
+ // a programmatic rebuild never emit it), so this guard is stricter
+ // than it needs to be today -- kept anyway, at zero behavioural
+ // cost, as a belt-and-suspenders match for the previous
+ // ComboRow-based code's guard against reacting to its own rebuilds.
+ private bool updating = false;
+
+ private void update_controls() {
+ if (search_row != null) search_row.sensitive = !imports.busy;
+ refresh.sensitive = !imports.busy && !loading;
+ foreach (var card in cards)
+ card.button.sensitive = !imports.busy && !imports.is_added(card.item.key);
+ // Filter UI is filter UI, not destructive: a busy import does
+ // not warrant disabling it, but a still-loading grid would
+ // mean picking a category changes nothing visible yet, so the
+ // category dropdown disables while loading.
+ category_row.sensitive = !loading;
+ previous_page.sensitive = !loading && !imports.busy && photo_page > 1;
+ next_page.sensitive = !loading && !imports.busy && photo_page < photo_page_count;
+ online_search.sensitive = !imports.busy && !loading;
+ if (loading || imports.busy) spinner.start(); else spinner.stop();
+ }
+
+ private static void stop_helper(Subprocess process) {
+ // Import invokes ImageMagick children. Stop the whole private process
+ // group so a timeout cannot leave a writer running after Retry.
+ string? identifier = process.get_identifier();
+ int pid = 0;
+ if (identifier != null && int.try_parse(identifier, out pid) && pid > 1)
+ Posix.kill((Posix.pid_t) (-pid), Posix.Signal.KILL);
+ process.force_exit();
+ }
+
+ private async string command(string[] argv, Cancellable? cancel, uint timeout, string? input = null) throws Error {
+ var launcher = new SubprocessLauncher(SubprocessFlags.STDIN_PIPE | SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+ if (force_refresh) launcher.setenv("NCZ_WALLPAPER_REFRESH", "1", true);
+ launcher.set_child_setup(() => { Posix.setsid(); });
+ var process = launcher.spawnv(argv);
+ bool timed_out = false;
+ uint timer = Timeout.add_seconds(timeout, () => {
+ timed_out = true;
+ stop_helper(process);
+ return Source.REMOVE;
+ });
+ ulong cancel_handler = 0;
+ if (cancel != null) {
+ cancel_handler = cancel.cancelled.connect(() => stop_helper(process));
+ if (cancel.is_cancelled()) stop_helper(process);
+ }
+ string output;
+ string errors;
+ try {
+ // Drain and reap even after cancellation, then discard the result.
+ yield process.communicate_utf8_async(input, null, out output, out errors);
+ } catch (Error e) {
+ stop_helper(process);
+ yield process.wait_async(null);
+ throw e;
+ } finally {
+ if (!timed_out) Source.remove(timer);
+ if (cancel_handler != 0) cancel.disconnect(cancel_handler);
+ }
+ if (cancel != null) cancel.set_error_if_cancelled();
+ if (timed_out) throw new IOError.TIMED_OUT(_("Wallpaper request timed out. Try again."));
+ if (!process.get_successful()) {
+ string detail = errors.strip();
+ if (detail.length > 300) detail = detail.substring(0, 300).make_valid();
+ throw new IOError.FAILED(detail != "" ? detail : _("Wallpaper helper failed."));
+ }
+ return output;
+ }
+
+ private async void initialize() {
+ try {
+ uint8[] contents;
+ yield File.new_for_path("/usr/share/ncz-wallpapers/ocs-category-index.json").load_contents_async(null, out contents, null);
+ category_index = (string) contents;
+ WallpaperOcs.categories(category_index, "ocs");
+ } catch (Error e) {
+ category_index = "";
+ }
+ rebuild_provider_row("ocs");
+ select_provider("ocs");
+ }
+
+ // A provider's display name is not always known at construction: Bing
+ // only learns whether it is serving the de-duplicated combined view
+ // once its helper has answered `markets`, and renames itself to
+ // "Bing (Combined, All Markets)" when it is. So the row is rebuilt
+ // from the registry's live names rather than snapshotted once, and
+ // select_provider_choices() calls this again after a load.
+ private void rebuild_provider_row(string current) {
+ providers.clear();
+ var options = new Gee.ArrayList();
+ foreach (var provider in provider_registry.get_active()) {
+ providers.add(new WallpaperOcsChoice(provider.id, _(provider.display_name)));
+ options.add(new Singularity.Core.AppSettingOption() {
+ id = provider.id, label = _(provider.display_name) });
+ }
+ updating = true;
+ set_choices(provider_row, options, current);
+ updating = false;
+ }
+
+ private async void credential_status() {
+ try {
+ string data = yield command({OPENVERSE_HELPER, "status"}, null, 15);
+ var obj = WallpaperOcs.document(data, false);
+ var registered = obj.get_member("registered");
+ bool saved = registered != null && registered.get_value_type() == typeof(bool) && registered.get_boolean();
+ openverse_credentials.set_state(saved ? _("Credentials saved. Verify your email using the Openverse link.")
+ : _("Anonymous access is available without registration."), !saved);
+ } catch (Error e) {
+ openverse_credentials.set_state(e.message, true);
+ }
+ try {
+ string data = yield command({UNSPLASH_HELPER, "status"}, null, 15);
+ var obj = WallpaperOcs.document(data, false);
+ var configured = obj.get_member("configured");
+ bool saved = configured != null && configured.get_value_type() == typeof(bool) && configured.get_boolean();
+ unsplash_credentials.set_state(saved ? _("Unsplash Access Key saved.")
+ : _("Add your Access Key to include Unsplash results."), !saved);
+ } catch (Error e) {
+ unsplash_credentials.set_state(_("Unsplash helper unavailable: %s").printf(e.message), true);
+ }
+ }
+
+ private async void register_openverse(string email) {
+ openverse_credentials.set_state(_("Registering with Openverse…"), false);
+ try {
+ string data = yield command({OPENVERSE_HELPER, "register"}, null, 90, email);
+ var obj = WallpaperOcs.document(data, false);
+ openverse_credentials.set_state(WallpaperOcs.text(obj, "message"), false);
+ } catch (Error e) {
+ openverse_credentials.set_state(e.message, true);
+ }
+ }
+
+ private async void configure_unsplash(string key) {
+ unsplash_credentials.set_state(_("Saving Unsplash Access Key…"), false);
+ try {
+ string data = yield command({UNSPLASH_HELPER, "configure"}, null, 30, key);
+ var obj = WallpaperOcs.document(data, false);
+ unsplash_credentials.set_state(WallpaperOcs.text(obj, "message"), false);
+ photo_page = 1;
+ browse_all.begin();
+ } catch (Error e) {
+ unsplash_credentials.set_state(e.message, true);
+ }
+ }
+
+ private async void browse_stock(WallpaperProvider provider) {
+ int gen = ++generation;
+ request.cancel();
+ request = new Cancellable();
+ var cancel = request;
+ loading = true;
+ cards.clear();
+ reset_thumbnail_loading();
+ grid.remove_all();
+ status.label = _("Searching Stock Photos…");
+ update_controls();
+ bool refresh_now = force_refresh;
+ force_refresh = false;
+ photo_page_count = 1;
+ string result_status = "";
+ try {
+ var result = yield provider.browse("", online_search.text, photo_page, refresh_now, cancel);
+ if (gen != generation) return;
+ photo_page_count = result.page_count;
+ foreach (var item in result.items) add_card(item);
+ result_status = result.stale ? _("Showing cached %s results; refresh failed.").printf(provider.display_name)
+ : _("%s · page %d of %d · %d images").printf(provider.display_name, photo_page, photo_page_count, cards.size);
+ } catch (Error e) {
+ result_status = _("%s search failed: %s").printf(provider.display_name, e.message);
+ }
+ if (gen != generation) return;
+ loading = false;
+ filter_cards();
+ status.label = result_status;
+ queue_viewport_thumbnails();
+ update_controls();
+ }
+
+ // Provider selected -> rebuild the category dropdown. Unlike the
+ // previous aggregate-crawl behaviour, selecting a provider no longer
+ // starts loading anything by itself: the category list is cheap
+ // metadata, but the wallpapers inside a category are not, so the
+ // grid stays empty until the user actually picks one (see
+ // on_category_row_selected() / browse_category()). The one
+ // exception is a provider whose dropdown collapses to a single
+ // choice (Bing's de-duplicated combined view) -- there is nothing
+ // to pick, so that one choice loads immediately, same as before.
+ private void select_provider(string provider_id) {
+ if (provider_id == "") return;
+ force_refresh = false;
+ var provider = provider_registry.lookup(provider_id);
+ if (provider == null) return;
+ bool photos = provider.supports_search;
+ openverse_credentials.visible = provider_id == "openverse";
+ unsplash_credentials.visible = provider_id == "unsplash";
+ online_search_group.visible = photos;
+ category_row.visible = !photos;
+ active_category_id = "";
+ if (photos) {
+ photo_page = 1;
+ categories.clear();
+ if (provider.requires_credentials || provider_id == "openverse") credential_status.begin();
+ browse_all.begin();
+ return;
+ }
+ if (provider_id == BING_PROVIDER_ID) {
+ select_provider_choices.begin(provider);
+ return;
+ }
+ if (category_index == "") {
+ generation++;
+ request.cancel();
+ cards.clear();
+ reset_thumbnail_loading();
+ grid.remove_all();
+ loading = false;
+ status.label = _("OCS category index is missing. Install the wallpaper helpers, then reopen this page.");
+ update_controls();
+ return;
+ }
+ select_provider_choices.begin(provider);
+ }
+
+ // Bing equivalent of the OCS provider/category-index load: one
+ // synchronous `ncz-wallpaper-bing markets` call, TSV-parsed into the
+ // same WallpaperOcsChoice list the category dropdown already knows how to
+ // render. Errors are surfaced through `status` exactly like an OCS
+ // category-index parse failure.
+ private async void select_provider_choices(WallpaperProvider provider) {
+ int gen = ++generation;
+ request.cancel();
+ request = new Cancellable();
+ var cancel = request;
+ loading = true;
+ status.label = _("Loading %s choices…").printf(provider.display_name);
+ update_controls();
+ try {
+ var loaded = yield provider.choices(category_index, cancel);
+ if (gen != generation) return;
+ // The provider may have renamed itself off the back of that
+ // answer (Bing -> "Bing (Combined, All Markets)"), so the
+ // "Online source" row is re-labelled before the grid fills.
+ // Keeping the current selection is what makes this safe to do
+ // mid-flight -- it rebuilds labels, never the selection.
+ rebuild_provider_row(provider.id);
+ categories = loaded;
+ active_category_id = "";
+ rebuild_category_row();
+ cards.clear();
+ reset_thumbnail_loading();
+ grid.remove_all();
+ loading = false;
+ update_controls();
+ if (!category_row.visible && categories.size == 1) {
+ // Nothing to pick (Bing's single combined choice, or any
+ // provider that happens to expose exactly one usable
+ // category) -- load it directly, same as before.
+ active_category_id = categories[0].id;
+ browse_category.begin(active_category_id);
+ } else {
+ status.label = _("Select a category to browse.");
+ }
+ } catch (Error e) {
+ if (gen != generation) return;
+ loading = false;
+ status.label = _("Could not load %s choices: %s").printf(provider.display_name, e.message);
+ update_controls();
+ }
+ }
+
+ private void rebuild_category_row() {
+ var options = new Gee.ArrayList();
+ options.add(new Singularity.Core.AppSettingOption() { id = "", label = _("Select a category…") });
+ foreach (var choice in categories) {
+ options.add(new Singularity.Core.AppSettingOption() { id = choice.id, label = choice.name });
+ }
+ // A dropdown with one real choice offers nothing to pick. Bing's
+ // de-duplicated combined view is a single choice by
+ // construction and loads directly (see select_provider_choices).
+ category_row.visible = categories.size > 1;
+ bool was_updating = updating;
+ updating = true;
+ set_choices(category_row, options, active_category_id);
+ updating = was_updating;
+ }
+
+ private void on_category_row_selected(string id) {
+ active_category_id = id;
+ if (id == "") {
+ generation++;
+ request.cancel();
+ cards.clear();
+ reset_thumbnail_loading();
+ grid.remove_all();
+ loading = false;
+ status.label = _("Select a category to browse.");
+ update_controls();
+ return;
+ }
+ browse_category.begin(id);
+ }
+
+ // Top-level dispatcher: re-runs whichever view is currently active
+ // (a stock-photo search, or the selected category), used by Refresh,
+ // by re-entering the page (see the `map` handler in the
+ // constructor), and by pagination. If nothing is selected yet there
+ // is nothing to refresh, so this is a no-op -- the "load only when a
+ // category is picked" behaviour lives in on_category_row_selected().
+ private async void browse_all() {
+ var selected_provider = provider_registry.lookup(provider_row.current_value);
+ if (selected_provider != null && selected_provider.supports_search) {
+ yield browse_stock(selected_provider);
+ return;
+ }
+ if (provider_row.current_value == "") {
+ loading = false;
+ status.label = _("No usable wallpaper providers.");
+ update_controls();
+ return;
+ }
+ if (active_category_id == "") return;
+ yield browse_category(active_category_id);
+ }
+
+ // Load (or refresh) exactly one category: the aggregate, load-every-
+ // category-up-front crawl this used to run on every provider select
+ // is gone. A crawl now touches only the category the user picked,
+ // which is what keeps a provider with dozens of categories and
+ // thousands of combined items from ever pulling more than one
+ // category's worth of results at a time. Re-entering this method
+ // for the SAME category (Refresh, or re-visiting the page) is the
+ // "refresh the index" half of that -- it reuses the exact same
+ // cache-then-revalidate-if-stale flow load_cached()/
+ // revalidate_cached() already provided, just re-scoped from
+ // "one file per provider" to "one file per provider+category" (see
+ // WallpaperBrowseCache.path_for()).
+ private async void browse_category(string category) {
+ int gen = ++generation;
+ request.cancel();
+ request = new Cancellable();
+ var cancel = request;
+ if (provider_row.current_value == "") {
+ loading = false;
+ status.label = _("No usable wallpaper providers.");
+ update_controls();
+ return;
+ }
+ string provider = provider_row.current_value;
+ var selected_provider = provider_registry.lookup(provider);
+ var todo = new ArrayList();
+ todo.add(category);
+ int total = 1;
+ cards.clear();
+ reset_thumbnail_loading();
+ grid.remove_all();
+ string category_name = category;
+ foreach (var c in categories) if (c.id == category) { category_name = c.name; break; }
+ // A crawl younger than its TTL is repainted from disk; the network
+ // crawl below only runs when that cache is stale, absent, corrupt,
+ // or explicitly bypassed by Refresh. The free-text filter is
+ // applied client-side to this same loaded list, so a filter
+ // change is a different VIEW, never a different crawl.
+ if (!force_refresh) {
+ var cache_result = yield load_cached(provider, category, gen, cancel);
+ if (cache_result == CacheLoadResult.FRESH) return;
+ if (cache_result == CacheLoadResult.STALE) {
+ revalidate_cached.begin(selected_provider, provider, category, todo, gen, cancel);
+ return;
+ }
+ }
+ loading = true;
+ status.label = _("Loading %s…").printf(category_name);
+ update_controls();
+ // Shared crawl state -- heap-allocated so the workers can read
+ // it; counters + queue are protected by the mutexes inside it.
+ var state = new CrawlState();
+ state.generation = gen;
+ state.provider = provider;
+ state.backend = selected_provider;
+ state.todo = todo;
+ state.total = total;
+ state.cancel = cancel;
+ // Pool of workers. With a single category queued there is only
+ // ever one unit of real work; the pool degrades to one active
+ // worker automatically (the loop below still polls correctly).
+ worker.begin(state);
+ for (int i = 1; i < CRAWL_WORKERS && i < total; i++)
+ worker.begin(state);
+ // Poll completion at 100 ms intervals. A timeout must invoke the
+ // async continuation; changing a flag cannot resume a bare yield.
+ while (gen == generation && state.done_count < total && !cancel.is_cancelled()) {
+ state.count_lock.lock();
+ int snapshot;
+ try { snapshot = state.item_count; } finally { state.count_lock.unlock(); }
+ if (snapshot >= CRAWL_ITEM_CAP) {
+ // Workers check the same cap before adding cards or
+ // starting another category. Keep the request alive for
+ // thumbnails; closing/restarting still cancels both.
+ break;
+ }
+ SourceFunc resume = browse_category.callback;
+ Timeout.add(100, () => {
+ if (resume != null) {
+ SourceFunc cb = (owned) resume;
+ resume = null;
+ cb();
+ }
+ return Source.REMOVE;
+ });
+ yield;
+ }
+ if (gen != generation) return;
+ loading = false;
+ force_refresh = false;
+ filter_cards();
+ if (state.errors.size > 0)
+ status.label = _("%d wallpapers loaded · %s").printf(cards.size, string.joinv(" · ", state.errors.to_array()));
+ update_controls();
+ // Persist what the crawl actually merged, so the next visit can
+ // skip it. A cancelled crawl is a partial view of the user's
+ // intent, not a result, and is never written. A crawl that lost
+ // categories to errors is written but marked partial, which gives
+ // it a much shorter TTL than a clean one.
+ if (!cancel.is_cancelled() && cards.size > 0)
+ store_cache(provider, category, state.errors.size > 0);
+ queue_viewport_thumbnails();
+ }
+
+ // Repaint from any valid on-disk snapshot. Freshness controls whether
+ // browse_category() is finished or starts a silent background
+ // revalidate; it never controls whether already persisted metadata
+ // can be shown.
+ private async CacheLoadResult load_cached(string provider, string category, int gen, Cancellable cancel) {
+ int64 at = WallpaperBrowseCache.now();
+ WallpaperBrowseCache? cached = null;
+ string path = WallpaperBrowseCache.path_for(provider, category);
+ if (FileUtils.test(path, FileTest.IS_REGULAR)) {
+ try {
+ string data;
+ FileUtils.get_contents(path, out data);
+ cached = WallpaperBrowseCache.parse(data, provider);
+ } catch (Error e) {
+ message("Discarding unreadable wallpaper browse cache %s: %s", path, e.message);
+ }
+ }
+ if (cached == null || cached.entries.size == 0) return CacheLoadResult.NONE;
+ yield populate_cards_batched(cached.entries, gen, cancel);
+ // Superseded while this repaint was still batching in (provider
+ // switch, Refresh): that newer call owns the page now, and
+ // touching status/controls here would fight it.
+ if (gen != generation || cancel.is_cancelled()) return CacheLoadResult.FRESH;
+ loading = false;
+ filter_cards();
+ // filter_cards() has just written the shown/loaded counts; append
+ // the provenance so a cached grid never silently poses as a fresh
+ // crawl, and name the way out of it.
+ status.label = _("%d wallpapers · %s · Refresh for new uploads").printf(
+ cards.size, cache_age(cached.age(at)));
+ update_controls();
+ queue_viewport_thumbnails();
+ return cached.fresh(at) ? CacheLoadResult.FRESH : CacheLoadResult.STALE;
+ }
+
+ // Build one WallpaperCard per entry in bounded batches, yielding to
+ // the main loop between batches so a large category (see
+ // CARD_BUILD_BATCH_SIZE) cannot hold the compositor unresponsive for
+ // seconds at a time. Thumbnails are queued after every batch too, so
+ // the initially visible rows start decoding as soon as they exist
+ // instead of waiting for the whole list to finish building.
+ private async void populate_cards_batched(ArrayList entries,
+ int gen, Cancellable cancel) {
+ int processed = 0;
+ foreach (var entry in entries) {
+ if (gen != generation || cancel.is_cancelled()) return;
+ add_card(entry.item);
+ if (++processed % CARD_BUILD_BATCH_SIZE != 0) continue;
+ queue_viewport_thumbnails();
+ SourceFunc resume = populate_cards_batched.callback;
+ Idle.add(() => {
+ if (resume != null) {
+ SourceFunc cb = (owned) resume;
+ resume = null;
+ cb();
+ }
+ return Source.REMOVE;
+ });
+ yield;
+ }
+ }
+
+ // Crawl into a detached metadata list while the stale card hierarchy
+ // remains mounted. Rebuilding the live grid happens synchronously in
+ // one main-loop turn, so GTK cannot paint an empty intermediate view.
+ private async void revalidate_cached(WallpaperProvider backend, string provider, string category,
+ ArrayList todo, int gen, Cancellable cancel) {
+ if (todo.size == 0) return;
+ var state = new CrawlState();
+ state.background = true;
+ state.generation = gen;
+ state.provider = provider;
+ state.backend = backend;
+ state.todo = todo;
+ state.total = todo.size;
+ state.cancel = cancel;
+ worker.begin(state);
+ for (int i = 1; i < CRAWL_WORKERS && i < state.total; i++) worker.begin(state);
+ while (gen == generation && state.done_count < state.total && !cancel.is_cancelled()) {
+ SourceFunc resume = revalidate_cached.callback;
+ Timeout.add(100, () => {
+ if (resume != null) {
+ SourceFunc cb = (owned) resume;
+ resume = null;
+ cb();
+ }
+ return Source.REMOVE;
+ });
+ yield;
+ }
+ if (gen != generation || cancel.is_cancelled()) return;
+ if (state.results.size == 0) {
+ warning("Background wallpaper cache revalidation for %s/%s produced no usable results", provider, category);
+ return;
+ }
+ WallpaperBrowseCache.save(provider, category, state.results,
+ state.errors.size > 0, WallpaperBrowseCache.now());
+ cards.clear();
+ reset_thumbnail_loading();
+ grid.remove_all();
+ // The first batch lands in this same synchronous continuation
+ // (populate_cards_batched only yields after CARD_BUILD_BATCH_SIZE
+ // cards), so the grid goes straight from the stale list to real
+ // new content with no empty frame in between; only the remaining
+ // batches spread across further main-loop turns.
+ yield populate_cards_batched(state.results, gen, cancel);
+ if (gen != generation || cancel.is_cancelled()) return;
+ filter_cards();
+ if (state.errors.size > 0)
+ status.label = _("%d wallpapers loaded · %s").printf(
+ cards.size, string.joinv(" · ", state.errors.to_array()));
+ update_controls();
+ queue_viewport_thumbnails();
+ }
+
+ private void store_cache(string provider, string category, bool partial) {
+ var snapshot = new ArrayList();
+ foreach (var card in cards)
+ snapshot.add(new WallpaperBrowseCacheEntry(card.item, category));
+ WallpaperBrowseCache.save(provider, category, snapshot, partial, WallpaperBrowseCache.now());
+ }
+
+ private static string cache_age(int64 seconds) {
+ if (seconds < 120) return _("loaded just now");
+ if (seconds < 7200) return _("loaded %d minutes ago").printf((int) (seconds / 60));
+ return _("loaded %d hours ago").printf((int) (seconds / 3600));
+ }
+
+ // Shared, heap-allocated crawl state. Vala forbids ref/out parameters
+ // on async methods, so the worker pool pulls counters and the
+ // pending-queue through this object instead of by reference.
+ // Multiple workers may touch it concurrently; the two mutexes in
+ // CrawlState serialise the queue pull and the counters.
+ private class CrawlState : Object {
+ public ArrayList errors = new ArrayList();
+ public ArrayList results = new ArrayList();
+ public HashSet seen_keys = new HashSet();
+ public HashSet seen_ocs_ids = new HashSet();
+ public bool background;
+ public int generation;
+ public string provider;
+ public WallpaperProvider backend;
+ public ArrayList todo = new ArrayList();
+ public int next_index;
+ public int done_count;
+ public int item_count;
+ public int total;
+ public Cancellable cancel;
+ public Mutex todo_lock = new Mutex();
+ public Mutex count_lock = new Mutex();
+ }
+
+ // One worker in the bounded crawl pool. Pulls ids off the shared
+ // todo queue inside CrawlState, runs the per-category browse, and
+ // merges results back into the same state. The per-category browse
+ // itself runs one subprocess per call via the existing command()
+ // helper, so no extra concurrency limiter is needed there.
+ private async void worker(CrawlState state) {
+ // Read everything through state.X; never capture local refs.
+ while (state.generation == generation && !state.cancel.is_cancelled()) {
+ int my_index = 0;
+ state.todo_lock.lock();
+ try {
+ if (state.next_index >= state.todo.size) {
+ return;
+ }
+ my_index = state.next_index++;
+ } finally {
+ state.todo_lock.unlock();
+ }
+ // Pre-check the cap so we never even spawn the subprocess
+ // for a category we are going to discard.
+ state.count_lock.lock();
+ bool cap_hit = false;
+ try {
+ if (state.item_count >= CRAWL_ITEM_CAP) cap_hit = true;
+ } finally {
+ state.count_lock.unlock();
+ }
+ if (cap_hit) {
+ state.count_lock.lock();
+ int d;
+ try { d = ++state.done_count; } finally { state.count_lock.unlock(); }
+ Idle.add(() => {
+ if (!state.background && state.generation == generation)
+ status.label = _("Loaded %d/%d · %d wallpapers (cap reached)").printf(d, state.total, state.item_count);
+ return Source.REMOVE;
+ });
+ return;
+ }
+ string category = state.todo[my_index];
+ string? error = null;
+ try {
+ var result = yield state.backend.browse(category, "", 1, force_refresh, state.cancel);
+ if (state.generation != generation || state.cancel.is_cancelled()) return;
+ var items = result.items;
+ if (result.warning != "") error = _(result.warning);
+ foreach (var item in items) {
+ if (state.background) {
+ bool duplicate = state.seen_keys.contains(item.key) ||
+ (WallpaperOcs.provider_id(item.provider_id) && state.seen_ocs_ids.contains(item.id));
+ if (duplicate) continue;
+ } else if (has_card(item)) continue;
+ // Re-check the cap under the lock so two workers
+ // can never both push past it on the last item.
+ state.count_lock.lock();
+ bool overflow = false;
+ try {
+ if (state.item_count >= CRAWL_ITEM_CAP) {
+ overflow = true;
+ } else {
+ state.item_count++;
+ }
+ } finally {
+ state.count_lock.unlock();
+ }
+ if (overflow) break;
+ if (state.background) {
+ state.seen_keys.add(item.key);
+ if (WallpaperOcs.provider_id(item.provider_id)) state.seen_ocs_ids.add(item.id);
+ state.results.add(new WallpaperBrowseCacheEntry(item, category));
+ } else {
+ add_card(item);
+ }
+ }
+ } catch (Error e) {
+ if (e is GLib.IOError.CANCELLED) return;
+ error = e.message;
+ }
+ state.count_lock.lock();
+ int d;
+ int snap;
+ if (error != null && !state.errors.contains(error)) state.errors.add(error);
+ try { d = ++state.done_count; snap = state.item_count; } finally { state.count_lock.unlock(); }
+ // Status updates live on the main thread. The captured
+ // `d`/`snap`/`category`/`error` are local-scope value
+ // captures -- safe to use in the Idle callback that fires
+ // after this async function yields.
+ Idle.add(() => {
+ if (state.generation != generation) return Source.REMOVE;
+ if (state.background) return Source.REMOVE;
+ if (error != null) {
+ // One bad category must not block the others; just
+ // surface it in the status line alongside the count.
+ status.label = _("Loaded %d/%d · %d wallpapers · %s failed: %s").printf(d, state.total, snap, category, error);
+ } else {
+ status.label = _("Loaded %d/%d · %d wallpapers so far").printf(d, state.total, snap);
+ }
+ return Source.REMOVE;
+ });
+ }
+ }
+
+ // Called under updating so a selection notification cannot start a
+ // crawl against a partially replaced option list. SelectionRow
+ // stores id/label pairs directly (current_value is the id), so
+ // unlike the previous combo-row code there is no separate
+ // position -> id array to maintain.
+ private static void set_choices(SelectionRow row,
+ Gee.ArrayList options, string current) {
+ row.set_options(options);
+ row.current_value = current;
+ }
+
+ private void filter_cards() {
+ string query = (search_row != null ? search_row.text : "").strip().casefold();
+ int count = 0;
+ foreach (var card in cards) {
+ card.matches = card_matches(card.item, query);
+ if (card.matches) count++;
+ }
+ grid.invalidate_filter();
+ queue_viewport_thumbnails();
+ if (!loading && !imports.busy) {
+ if (cards.size == 0) status.label = _("No importable wallpapers for this category.");
+ else if (count == 0) status.label = _("No matches among loaded wallpapers. Clear the filter or refresh.");
+ else status.label = _("%d wallpapers shown · %d loaded").printf(count, cards.size);
+ }
+ }
+
+ private bool filter_grid_child(FlowBoxChild child) {
+ int index = child.get_index();
+ return index >= 0 && index < cards.size &&
+ cards[index].card == child.child && cards[index].matches;
+ }
+
+ // Free-text match against name + author only. Category is no longer
+ // a client-side filter over an aggregate list -- a crawl now covers
+ // exactly one category, so every card already in `cards` belongs to
+ // the one the user picked (see browse_category()).
+ private bool card_matches(WallpaperItem item, string query) {
+ if (query != "")
+ return (item.name + " " + item.author).casefold().contains(query);
+ return true;
+ }
+
+
+ // (Re)build a card for one wallpapers item. The visible chrome is a
+ // WallpaperCard (visual parity with the main Desktop wallpaper
+ // picker -- same 172x104 clipped rounded frame, same Picture with
+ // ContentFit.COVER, same title overlay with object-select check,
+ // same wallpaper-card / workspace-preview CSS classes). Action
+ // button (Import / Pin) attaches through WallpaperCard.set_action_
+ // button() so the visual chrome stays consistent with the local
+ // picker's trash button. Attribution + licence live as a small
+ // badge on the card via WallpaperCard.set_badge().
+ private bool has_card(WallpaperItem item) {
+ foreach (var existing in cards) {
+ if (existing.item.key == item.key ||
+ (WallpaperOcs.provider_id(existing.item.provider_id) && WallpaperOcs.provider_id(item.provider_id) && existing.item.id == item.id)) return true;
+ }
+ return false;
+ }
+
+ private void add_card(WallpaperItem item) {
+ if (has_card(item)) return;
+ if (item.provider_id != "openverse" && item.provider_id != "unsplash") {
+ item.name = WallpaperSidecar.plain_text(item.name);
+ item.author = WallpaperSidecar.plain_text(item.author);
+ item.license = WallpaperSidecar.plain_text(item.license);
+ }
+ var card = new OcsCard();
+ card.item = item;
+ // Use placeholder_only: the OCS browser drives its own async
+ // thumbnail load (with generation/close guards) via load_one_
+ // thumbnail() rather than letting WallpaperCard's built-in
+ // worker handle it (which has no generation awareness).
+ string card_title = item.name != "" ? item.name : (item.provider_id == BING_PROVIDER_ID ? _("Bing wallpaper") : _("Wallpaper"));
+ card.card = new WallpaperCard.placeholder_only(item.key, card_title);
+ // Attribution / licence badge. OCS shows uploader · provider;
+ // Bing shows market · "Bing". Honour dim-label style so the
+ // badge reads as supporting text, not primary title.
+ string attribution;
+ if (item.provider_id == BING_PROVIDER_ID)
+ attribution = "%s · %s".printf(_("Bing"), item.market != "" ? item.market : item.provider_id);
+ else
+ attribution = "%s · %s".printf(item.author != "" ? item.author : _("Unknown uploader"),
+ item.provider_id == "openverse" ? _("Openverse") : item.provider_id == "unsplash" ? _("Unsplash") : _("OCS Network"));
+ string license_text = item.license != "" ? item.license : _("No license stated");
+ card.card.set_badge(attribution + " · " + license_text);
+ if (item.provider_id == "openverse" || item.provider_id == "unsplash") {
+ var metadata = WallpaperAttribution() { title = "", author = item.attribution != "" ? item.attribution : item.author,
+ source = (item.provider_id == "unsplash" ? "Unsplash · " : "Openverse · ") + item.license,
+ page_url = item.page_url, license_url = item.license_url, valid = true };
+ var credit = new Label(WallpaperSidecar.display_text(metadata));
+ credit.use_markup = false;
+ credit.wrap = true;
+ credit.selectable = true;
+ credit.max_width_chars = 28;
+ card.card.append(credit);
+ if (item.provider_id == "unsplash" && (item.creator_url.has_prefix("https://") || item.creator_url.has_prefix("http://")))
+ card.card.append(new LinkButton.with_label(item.creator_url, _("Photographer on Unsplash")));
+ if (item.page_url.has_prefix("https://") || item.page_url.has_prefix("http://"))
+ card.card.append(new LinkButton.with_label(item.page_url, _("Original image / attribution")));
+ if (item.license_url.has_prefix("https://") || item.license_url.has_prefix("http://"))
+ card.card.append(new LinkButton.with_label(item.license_url, item.license));
+ }
+ // Card click: WallpaperCard emits clicked() on the GestureClick
+ // wired in build_card(); for OCS/Bing this is purely a visual
+ // affordance; importing is the meaningful action for searchable
+ // providers. Bing entries are already local. The checkmark stays
+ // decorative.
+ // Action button (Import / Added). Bing entries were added by the
+ // scheduled accumulator already, so there is no per-card action.
+ if (item.provider_id == BING_PROVIDER_ID) {
+ card.button = new Button.with_label(_("Added"));
+ card.button.sensitive = false;
+ } else {
+ card.button = new Button.with_label(imports.is_added(item.key) ? _("Added") : _("Import"));
+ card.button.clicked.connect(() => { import_card.begin(card); });
+ }
+ card.card.append_action_button(card.button);
+ grid.append(card.card);
+ cards.add(card);
+ }
+
+ private void reset_thumbnail_loading() {
+ thumbnail_requested.clear();
+ thumbnail_pending.clear();
+ }
+
+ // Recompute after allocation: cache/crawl repaint leaves the scroll
+ // value unchanged, while the newly appended FlowBox children do not
+ // have meaningful bounds until GTK's next layout pass.
+ private void queue_viewport_thumbnails() {
+ int gen = generation;
+ Idle.add(() => {
+ if (gen != generation || request.is_cancelled()) return Source.REMOVE;
+ double top = scroller.vadjustment.value;
+ double bottom = top + scroller.vadjustment.page_size;
+ bool queued = false;
+ for (int i = 0; i < cards.size; i++) {
+ var child = grid.get_child_at_index(i);
+ if (child == null) continue;
+ if (!cards[i].matches) {
+ thumbnail_requested.remove(i);
+ thumbnail_pending.remove(i);
+ cards[i].card.set_paintable(null);
+ continue;
+ }
+ Graphene.Rect bounds;
+ if (!child.compute_bounds(content_box, out bounds)) continue;
+ double child_top = bounds.origin.y;
+ double child_bottom = child_top + bounds.size.height;
+ bool near = child_bottom >= top - VIEWPORT_PREFETCH_MARGIN_PX &&
+ child_top <= bottom + VIEWPORT_PREFETCH_MARGIN_PX;
+ bool far = child_bottom < top - VIEWPORT_EVICT_MARGIN_PX ||
+ child_top > bottom + VIEWPORT_EVICT_MARGIN_PX;
+ if (near && thumbnail_requested.add(i)) {
+ thumbnail_pending.add(i);
+ queued = true;
+ } else if (far && thumbnail_requested.remove(i)) {
+ thumbnail_pending.remove(i);
+ cards[i].card.set_paintable(null);
+ }
+ }
+ if (queued)
+ for (int lane = 0; lane < THUMBNAIL_FETCH_LANES; lane++)
+ thumbnails.begin(lane, gen, request);
+ return Source.REMOVE;
+ });
+ }
+
+ // Async thumbnail loader, fanned out for newly visible cards.
+ // Two source paths:
+ // * Bing items: thumbnail_path is a local file (helper pre-
+ // downloaded the 400x240 JPEG before the `list` response was
+ // built). Read the bytes, then decode via MemoryInputStream so
+ // the loader does not block on the open InputStream (passing
+ // an already-open stream to from_stream_at_scale_async can
+ // deadlock on the read loop -- the loader assumes it owns the
+ // stream and reads it synchronously until EOF).
+ // * OCS items: item.preview is a remote URL, fetched via Soup.
+ // * Either path failure just leaves the placeholder visible;
+ // a missing preview must not prevent browsing or importing.
+ //
+ // Each thumbnail write is marshalled onto the main thread via
+ // Idle.add() so the Picture widget's set_paintable is always
+ // called from the UI thread (GTK4 widget APIs are not safe to
+ // call from arbitrary worker contexts).
+ private async void thumbnails(int start, int gen, Cancellable cancel) {
+ for (int i = start; i < cards.size && gen == generation && !cancel.is_cancelled(); i += THUMBNAIL_FETCH_LANES) {
+ if (!thumbnail_pending.remove(i)) continue;
+ var card = cards[i];
+ Gdk.Pixbuf? pixbuf = null;
+ if (card.item.thumbnail_path != "") {
+ // Bing local-file path.
+ try {
+ var file = File.new_for_path(card.item.thumbnail_path);
+ if (!file.query_exists()) {
+ show_thumb_unavailable(card);
+ continue;
+ }
+ var stream = yield file.read_async(Priority.DEFAULT, cancel);
+ // Drain to a ByteArray so we own the bytes: the
+ // loader does not have to fight an open file
+ // descriptor for sync reads.
+ var bytes = new ByteArray();
+ try {
+ while (true) {
+ var part = yield stream.read_bytes_async(65536, Priority.DEFAULT, cancel);
+ if (part.get_size() == 0) break;
+ if (bytes.len + part.get_size() > 4 * 1024 * 1024) {
+ throw new IOError.FAILED("Thumbnail exceeds size limit");
+ }
+ bytes.append(part.get_data());
+ }
+ } finally {
+ // Vala forbids `yield` inside finally, so the
+ // close is a plain call: a non-cancellable
+ // close on a Cancellable-bound stream is
+ // acceptable here -- we're throwing it away.
+ try { stream.close(); } catch (Error e) {}
+ }
+ if (cancel.is_cancelled()) continue;
+ var input = new MemoryInputStream.from_bytes(ByteArray.free_to_bytes((owned) bytes));
+ pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async(input, 344, 208, true, cancel);
+ } catch (Error e) {
+ if (!(e is GLib.IOError.CANCELLED)) show_thumb_unavailable(card);
+ continue;
+ }
+ } else {
+ // OCS Soup path.
+ string url = card.item.preview;
+ if (!url.has_prefix("https://") && !url.has_prefix("http://")) continue;
+ var cached = thumbnail_cache.get(url);
+ if (cached != null) {
+ try {
+ var input = new MemoryInputStream.from_bytes(cached);
+ pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async(input, 344, 208, true, cancel);
+ } catch (Error e) {
+ if (!(e is GLib.IOError.CANCELLED)) show_thumb_unavailable(card);
+ continue;
+ }
+ if (cancel.is_cancelled()) continue;
+ } else {
+ var message = new Soup.Message("GET", url);
+ if (message == null) continue;
+ InputStream? stream = null;
+ bool skip_card = false;
+ try {
+ stream = yield session.send_async(message, Priority.DEFAULT, cancel);
+ if (cancel.is_cancelled()) { skip_card = true; }
+ else if (message.status_code != 200) { skip_card = true; }
+ else {
+ var bytes = new ByteArray();
+ while (true) {
+ var part = yield stream.read_bytes_async(65536, Priority.DEFAULT, cancel);
+ if (part.get_size() == 0) break;
+ if (bytes.len + part.get_size() > 4 * 1024 * 1024) {
+ throw new IOError.FAILED("Thumbnail exceeds size limit");
+ }
+ bytes.append(part.get_data());
+ }
+ var fetched = ByteArray.free_to_bytes((owned) bytes);
+ thumbnail_cache.put(url, fetched);
+ var input = new MemoryInputStream.from_bytes(fetched);
+ pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async(input, 344, 208, true, cancel);
+ }
+ } catch (Error e) {
+ if (!(e is GLib.IOError.CANCELLED)) show_thumb_unavailable(card);
+ skip_card = true;
+ } finally {
+ // Synchronous close() (not close_async()) because
+ // Vala forbids yield inside finally. Soup response
+ // streams are safe to close synchronously.
+ if (stream != null) {
+ try { stream.close(); } catch (Error e) {}
+ }
+ }
+ if (skip_card) continue;
+ }
+ }
+ if (pixbuf == null) continue;
+ if (gen != generation || cancel.is_cancelled()) continue;
+ // Marshal the paintable assignment onto the main thread so
+ // Picture.set_paintable is always called from a UI context.
+ // The local var capture is safe: pixbuf is a fresh heap
+ // object and `card` is a strong ref into the cards[] list.
+ Gdk.Pixbuf captured_pb = pixbuf;
+ OcsCard captured_card = card;
+ int captured_index = i;
+ Idle.add(() => {
+ if (gen == generation && thumbnail_requested.contains(captured_index) && captured_card.card != null)
+ captured_card.card.set_paintable(Gdk.Texture.for_pixbuf(captured_pb));
+ return GLib.Source.REMOVE;
+ });
+ }
+ }
+
+ // Surface a "preview unavailable" tooltip on the card without
+ // touching the picture's paintable (the placeholder stays
+ // visible). Marshalled to the main thread so it can run from any
+ // async context safely.
+ private void show_thumb_unavailable(OcsCard card) {
+ Idle.add(() => {
+ if (generation >= 0 && card.card != null) {
+ // Tooltip on the WallpaperCard itself rather than on
+ // an internal Picture -- the picture is private.
+ card.card.tooltip_text = _("Preview unavailable");
+ }
+ return GLib.Source.REMOVE;
+ });
+ }
+
+ private async void import_card(OcsCard card) {
+ if (!imports.begin(card.item.key)) return;
+ card.button.label = _("Importing…");
+ status.label = _("Downloading and preparing wallpaper pack…");
+ update_controls();
+ try {
+ var provider = provider_registry.lookup(card.item.provider_id == "pling" || card.item.provider_id == "kde-look" || card.item.provider_id == "gnome-look" ? "ocs" : card.item.provider_id);
+ if (provider == null) throw new IOError.NOT_SUPPORTED("Wallpaper provider is not active.");
+ string data = yield provider.import_item(card.item, null);
+ imports.complete(card.item.key, data, collection_roots);
+ card.button.label = _("Added");
+ status.label = _("Theme pack updated. Choose it in Wallpaper Source.");
+ imported();
+ } catch (Error e) {
+ imports.fail(card.item.key);
+ card.button.label = _("Retry import");
+ status.label = _("Import failed: %s").printf(e.message);
+ }
+ update_controls();
+ }
+ }
+}
diff --git a/src/components/sidebar/views/settings_view.vala b/src/components/sidebar/views/settings_view.vala
index e93f258..ced4b8b 100644
--- a/src/components/sidebar/views/settings_view.vala
+++ b/src/components/sidebar/views/settings_view.vala
@@ -400,6 +400,13 @@ namespace Singularity {
return row;
}
+ private void notify_wallpaper_imported() {
+ if (_page_cache.has_key("desktop")) {
+ var desktop_page = _page_cache["desktop"] as DesktopPage;
+ if (desktop_page != null) desktop_page.refresh_after_import();
+ }
+ }
+
private Widget? build_page(string page_name, bool connect_navigation) {
Widget? page = null;
switch (page_name) {
@@ -420,6 +427,13 @@ namespace Singularity {
case "plugins": page = new Singularity.PluginsPage(this); break;
case "performance": page = new Singularity.SidebarPages.PerformancePage(this); break;
case "system": page = new Singularity.SidebarPages.SystemPage(this); break;
+ case "wallpaper-browser":
+ string[] roots = DesktopPage.compute_collection_roots();
+ page = new Singularity.Shell.WallpaperOcsBrowserPage(this, roots);
+ var browser = page as Singularity.Shell.WallpaperOcsBrowserPage;
+ if (browser != null)
+ browser.imported.connect(notify_wallpaper_imported);
+ break;
}
if (page == null) return null;
@@ -431,7 +445,10 @@ namespace Singularity {
sp.back_btn.visible = false;
} else {
sp.back_btn.visible = true;
- sp.back_clicked.connect(() => { go_home(); });
+ // This drill-down page returns to Desktop, not Settings home.
+ if (page_name != "wallpaper-browser") {
+ sp.back_clicked.connect(() => { go_home(); });
+ }
}
sp.adaptive_back_btn.clicked.connect(() => {
main_stack.visible_child_name = "sidebar";
diff --git a/src/core/settings_safety.vala b/src/core/settings_safety.vala
new file mode 100644
index 0000000..9e9ffc8
--- /dev/null
+++ b/src/core/settings_safety.vala
@@ -0,0 +1,23 @@
+using GLib;
+
+namespace Singularity.SettingsSafety {
+ private bool accepts(GLib.Settings settings, string key) {
+ if (!settings.settings_schema.has_key(key)) {
+ warning("Attempt to write unknown gsettings key '%s'; ignored.", key);
+ return false;
+ }
+ return true;
+ }
+
+ public bool set_string(GLib.Settings settings, string key, string value) {
+ return accepts(settings, key) && settings.set_string(key, value);
+ }
+
+ public bool set_value(GLib.Settings settings, string key, GLib.Variant value) {
+ return accepts(settings, key) && settings.set_value(key, value);
+ }
+
+ public bool set_strv(GLib.Settings settings, string key, string[] value) {
+ return accepts(settings, key) && settings.set_strv(key, value);
+ }
+}
diff --git a/src/core/wallpaper_browse_cache.vala b/src/core/wallpaper_browse_cache.vala
new file mode 100644
index 0000000..8e5ccb0
--- /dev/null
+++ b/src/core/wallpaper_browse_cache.vala
@@ -0,0 +1,269 @@
+using GLib;
+using Gee;
+
+namespace Singularity {
+ // One cached browse result. `item` is the wallpaper exactly as the crawl
+ // merged it; `category` is the choice id it was crawled from. The browser
+ // deliberately keeps that category in a side map rather than on
+ // WallpaperItem (see card_matches()), so it travels alongside the item
+ // here instead of inside it.
+ public class WallpaperBrowseCacheEntry : Object {
+ public WallpaperItem item;
+ public string category;
+ public WallpaperBrowseCacheEntry(WallpaperItem item, string category = "") {
+ this.item = item;
+ this.category = category;
+ }
+ }
+
+ // On-disk cache of one category's browse crawl, one file per
+ // provider+category.
+ //
+ // The browser crawls a single, user-picked category at a time and
+ // caches that category's result on its own (see
+ // WallpaperOcsBrowserPage.browse_category()) -- it no longer pulls
+ // every category into one aggregate before the user has chosen
+ // anything. The cache key is therefore PROVIDER + CATEGORY: picking a
+ // different category is a different crawl, and the only client-side
+ // filter left (free text) is applied within one category's cached
+ // list. Providers that search server-side (Openverse, Unsplash) are
+ // query- and page-addressed, are not part of this crawl, and already
+ // have their own helper-side caching, so they are not cached here.
+ // A provider whose dropdown collapses to a single choice (Bing's
+ // combined view) is cached under that one category id, same as any
+ // other category.
+ //
+ // The file is written by this shell and read back by it, but it is still
+ // parsed defensively: a truncated write, a half-full disk or a hand-edited
+ // file must degrade to "no cache" and a fresh crawl, never to a crash or a
+ // half-populated grid.
+ public class WallpaperBrowseCache : Object {
+ public const int SCHEMA = 1;
+ // Six hours. OCS listings accrete slowly (new uploads trickle in over
+ // days) and the Bing archive gains at most one image per market per
+ // day, so a full re-crawl on every page open buys almost nothing. Six
+ // hours keeps the several page opens in a working session instant
+ // while still picking up a day's new uploads a few times a day, and
+ // bounds how long a same-day Bing image can be missing. Refresh
+ // bypasses it entirely whenever the user wants fresher results now.
+ public const int64 TTL_SECONDS = 6 * 3600;
+ // A crawl in which some categories failed is still worth reusing for a
+ // page open a minute later, but must not pin a degraded snapshot for
+ // six hours while the failing network recovers.
+ public const int64 PARTIAL_TTL_SECONDS = 30 * 60;
+
+ public string provider = "";
+ public int64 created = 0;
+ // True when at least one category of the crawl failed, so the merged
+ // list is known to be missing wallpapers it would otherwise hold.
+ public bool partial = false;
+ public ArrayList entries = new ArrayList();
+
+ public static int64 now() { return new DateTime.now_utc().to_unix(); }
+
+ // Provider ids come from WallpaperProviderRegistry, but they are used
+ // to build a filename, so they are checked rather than trusted.
+ public static bool valid_provider(string provider) {
+ if (provider == "" || provider.length > 32) return false;
+ foreach (char c in provider.to_utf8())
+ if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '-') return false;
+ return true;
+ }
+
+ // Category ids come from the same OCS/Bing helper responses that
+ // feed the category dropdown, not from the user directly, but they
+ // also become part of a filename -- checked the same way as
+ // valid_provider(), with underscores allowed since real OCS
+ // category slugs use them.
+ public static bool valid_category(string category) {
+ if (category == "" || category.length > 64) return false;
+ foreach (char c in category.to_utf8())
+ if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') &&
+ !(c >= '0' && c <= '9') && c != '-' && c != '_') return false;
+ return true;
+ }
+
+ // XDG cache, namespaced under "singularity" the same way the shell's
+ // config and data live under get_user_config_dir()/"singularity" and
+ // get_user_data_dir()/"singularity". This is per-user browse state, not
+ // the system-wide image archives the helpers own in
+ // /var/cache/ncz-wallpapers.
+ public static string directory() {
+ return Path.build_filename(Environment.get_user_cache_dir(), "singularity", "wallpaper-browse");
+ }
+
+ // `category` is optional (defaults to "") to keep this callable the
+ // same way it always was; passing one namespaces the cache file
+ // under provider+category instead of provider alone -- see the
+ // class comment above for why a crawl is scoped that way now.
+ public static string path_for(string provider, string category = "") {
+ string name = category != "" ? provider + "_" + category : provider;
+ return Path.build_filename(directory(), name + ".json");
+ }
+
+ public bool fresh(int64 at) {
+ // A cache stamped in the future is a clock change, not a fresh
+ // crawl; treat it as stale so the worst case is one extra crawl.
+ if (created > at) return false;
+ return at - created < (partial ? PARTIAL_TTL_SECONDS : TTL_SECONDS);
+ }
+
+ public int64 age(int64 at) { return at > created ? at - created : 0; }
+
+ public static string serialize(string provider, Gee.List entries,
+ bool partial, int64 created) {
+ var builder = new Json.Builder();
+ builder.begin_object();
+ builder.set_member_name("schema"); builder.add_int_value(SCHEMA);
+ builder.set_member_name("provider"); builder.add_string_value(provider);
+ builder.set_member_name("created"); builder.add_int_value(created);
+ builder.set_member_name("partial"); builder.add_boolean_value(partial);
+ builder.set_member_name("items");
+ builder.begin_array();
+ foreach (var entry in entries) {
+ var item = entry.item;
+ builder.begin_object();
+ builder.set_member_name("provider"); builder.add_string_value(item.provider_id);
+ builder.set_member_name("id"); builder.add_string_value(item.id);
+ builder.set_member_name("category"); builder.add_string_value(entry.category);
+ builder.set_member_name("name"); builder.add_string_value(item.name);
+ builder.set_member_name("author"); builder.add_string_value(item.author);
+ builder.set_member_name("license"); builder.add_string_value(item.license);
+ builder.set_member_name("preview"); builder.add_string_value(item.preview);
+ builder.set_member_name("url"); builder.add_string_value(item.full_res_url);
+ builder.set_member_name("attribution"); builder.add_string_value(item.attribution);
+ builder.set_member_name("page_url"); builder.add_string_value(item.page_url);
+ builder.set_member_name("creator_url"); builder.add_string_value(item.creator_url);
+ builder.set_member_name("license_url"); builder.add_string_value(item.license_url);
+ builder.set_member_name("width"); builder.add_int_value(item.width);
+ builder.set_member_name("height"); builder.add_int_value(item.height);
+ builder.set_member_name("thumbnail_path"); builder.add_string_value(item.thumbnail_path);
+ builder.set_member_name("market"); builder.add_string_value(item.market);
+ builder.set_member_name("date"); builder.add_string_value(item.archive_date);
+ builder.set_member_name("image_id"); builder.add_string_value(item.bing_image_id);
+ builder.set_member_name("pinned"); builder.add_boolean_value(item.pinned);
+ builder.set_member_name("tags");
+ builder.begin_array();
+ foreach (string tag in item.tags) builder.add_string_value(tag);
+ builder.end_array();
+ builder.end_object();
+ }
+ builder.end_array();
+ builder.end_object();
+ var generator = new Json.Generator();
+ generator.set_root(builder.get_root());
+ return generator.to_data(null);
+ }
+
+ private static int64 timestamp(Json.Object obj, string field) throws Error {
+ var node = obj.get_member(field);
+ if (node == null || node.get_value_type() != typeof(int64) || node.get_int() < 0)
+ throw new WallpaperOcsError.INVALID("Invalid cached browse field: " + field);
+ return node.get_int();
+ }
+
+ private static bool flag(Json.Object obj, string field, bool required) throws Error {
+ var node = obj.get_member(field);
+ if (node == null || node.is_null()) {
+ if (!required) return false;
+ throw new WallpaperOcsError.INVALID("Missing cached browse field: " + field);
+ }
+ if (node.get_value_type() != typeof(bool))
+ throw new WallpaperOcsError.INVALID("Invalid cached browse field: " + field);
+ return node.get_boolean();
+ }
+
+ // Reuses the OCS document/field guards so a malformed cache is
+ // rejected by the same type checks that protect helper responses.
+ public static WallpaperBrowseCache parse(string data, string provider) throws Error {
+ var obj = WallpaperOcs.document(data);
+ if (WallpaperOcs.text(obj, "provider") != provider)
+ throw new WallpaperOcsError.INVALID("Cached browse result belongs to another provider");
+ var cache = new WallpaperBrowseCache();
+ cache.provider = provider;
+ cache.created = timestamp(obj, "created");
+ cache.partial = flag(obj, "partial", true);
+ var seen = new HashSet();
+ foreach (var node in WallpaperOcs.array(obj, "items").get_elements()) {
+ var record = WallpaperOcs.object_node(node);
+ var item = new WallpaperItem();
+ item.provider_id = WallpaperOcs.text(record, "provider");
+ item.id = WallpaperOcs.text(record, "id");
+ item.name = WallpaperOcs.text(record, "name", false);
+ item.author = WallpaperOcs.text(record, "author", false);
+ item.license = WallpaperOcs.text(record, "license", false);
+ item.preview = WallpaperOcs.text(record, "preview", false);
+ item.full_res_url = WallpaperOcs.text(record, "url", false);
+ item.attribution = WallpaperOcs.text(record, "attribution", false);
+ item.page_url = WallpaperOcs.text(record, "page_url", false);
+ item.creator_url = WallpaperOcs.text(record, "creator_url", false);
+ item.license_url = WallpaperOcs.text(record, "license_url", false);
+ item.width = WallpaperOcs.optional_int(record, "width");
+ item.height = WallpaperOcs.optional_int(record, "height");
+ item.thumbnail_path = WallpaperOcs.text(record, "thumbnail_path", false);
+ item.market = WallpaperOcs.text(record, "market", false);
+ item.archive_date = WallpaperOcs.text(record, "date", false);
+ item.bing_image_id = WallpaperOcs.text(record, "image_id", false);
+ item.pinned = flag(record, "pinned", false);
+ item.tags = WallpaperOcs.tag_array(record, "tags");
+ if (seen.add(item.key))
+ cache.entries.add(new WallpaperBrowseCacheEntry(item,
+ WallpaperOcs.text(record, "category", false)));
+ }
+ return cache;
+ }
+
+ // Null means "crawl": no file, an unreadable or malformed file, or a
+ // file older than its TTL. None of those are conditions the user needs
+ // to see -- the crawl that follows is the normal path.
+ public static WallpaperBrowseCache? read(string path, string provider, int64 at) {
+ if (!valid_provider(provider)) return null;
+ if (!FileUtils.test(path, FileTest.IS_REGULAR)) return null;
+ try {
+ string data;
+ FileUtils.get_contents(path, out data);
+ var cache = parse(data, provider);
+ return cache.fresh(at) ? cache : null;
+ } catch (Error e) {
+ // Logged, not raised, and deliberately not a warning: the
+ // caller crawls and the next crawl overwrites the bad file, so
+ // this is a self-healing condition rather than a fault. (It
+ // also keeps the GLib.Test harness, which makes warnings
+ // fatal, able to exercise the corrupt-cache path.)
+ message("Discarding unreadable wallpaper browse cache %s: %s", path, e.message);
+ return null;
+ }
+ }
+
+ // Best effort: a cache that cannot be written must never break
+ // browsing, so failures are logged and reported through the return
+ // value rather than raised at the call site.
+ public static bool write(string path, string provider, Gee.List entries,
+ bool partial, int64 created) {
+ if (!valid_provider(provider)) return false;
+ string dir = Path.get_dirname(path);
+ if (DirUtils.create_with_parents(dir, 0700) != 0) {
+ warning("Could not create wallpaper browse cache directory %s", dir);
+ return false;
+ }
+ try {
+ FileUtils.set_contents(path, serialize(provider, entries, partial, created));
+ return true;
+ } catch (Error e) {
+ warning("Could not write wallpaper browse cache %s: %s", path, e.message);
+ return false;
+ }
+ }
+
+ public static WallpaperBrowseCache? load(string provider, string category, int64 at) {
+ if (!valid_provider(provider) || !valid_category(category)) return null;
+ return read(path_for(provider, category), provider, at);
+ }
+
+ public static bool save(string provider, string category, Gee.List entries,
+ bool partial, int64 created) {
+ if (!valid_provider(provider) || !valid_category(category)) return false;
+ return write(path_for(provider, category), provider, entries, partial, created);
+ }
+ }
+}
diff --git a/src/core/wallpaper_collections.vala b/src/core/wallpaper_collections.vala
index 50d5151..9514915 100644
--- a/src/core/wallpaper_collections.vala
+++ b/src/core/wallpaper_collections.vala
@@ -10,13 +10,43 @@ namespace Singularity {
public string artist;
public string dir;
public string type;
+ public string origin;
+ public string registry_path;
+ public bool theme_pack;
+ public bool deletable;
+ private string deletion_home;
- public WallpaperCollectionInfo(string id, string name, string artist, string dir, string type) {
+ public WallpaperCollectionInfo(string id, string name, string artist, string dir, string type,
+ string origin = "", string registry_path = "",
+ string? user_home = null) {
this.id = id;
this.name = name;
this.artist = artist;
this.dir = dir;
this.type = type;
+ this.origin = origin;
+ this.registry_path = registry_path;
+ deletion_home = user_home ?? Environment.get_home_dir();
+ deletable = can_delete_now();
+ theme_pack = id == "pling" || id == "kde-look" || id == "gnome-look" ||
+ id == "bing" || id == "ocs" || id == "openverse" || id == "unsplash" || id == "imported-ocs" || id.has_prefix("ocs-");
+ }
+
+ private static bool path_is_within(string path, string parent) {
+ string? real_path = Posix.realpath(path, null);
+ string? real_parent = Posix.realpath(parent, null);
+ if (real_path == null || real_parent == null) return false;
+ return real_path == real_parent || real_path.has_prefix(real_parent + Path.DIR_SEPARATOR_S);
+ }
+
+ public bool contains_uri(string uri) {
+ string? path = File.new_for_uri(uri).get_path();
+ return path != null && path_is_within(path, dir);
+ }
+
+ public bool can_delete_now() {
+ return origin.strip() != "" && registry_path != "" &&
+ path_is_within(dir, deletion_home) && path_is_within(registry_path, deletion_home);
}
}
@@ -85,7 +115,12 @@ namespace Singularity {
catch (Error e) { type = ""; }
if (type == "") type = "static";
- results.add(new WallpaperCollectionInfo(id, name, artist, collection_dir, type));
+ string origin;
+ try { origin = kf.get_string("Collection", "Origin").strip(); }
+ catch (Error e) { origin = ""; }
+
+ results.add(new WallpaperCollectionInfo(id, name, artist, collection_dir, type,
+ origin, GLib.Path.build_filename(root, filename)));
}
} catch (Error e) {
continue;
@@ -93,5 +128,85 @@ namespace Singularity {
}
return results;
}
+
+
+ private static void remove_tree(File file) throws Error {
+ FileType type = file.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ if (type == FileType.DIRECTORY) {
+ var en = file.enumerate_children("standard::name", FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ FileInfo child;
+ while ((child = en.next_file(null)) != null)
+ remove_tree(file.get_child(child.get_name()));
+ }
+ file.delete(null);
+ }
+
+ private static int image_count(string dir) throws Error {
+ int count = 0;
+ var en = File.new_for_path(dir).enumerate_children(
+ "standard::content-type,standard::type", FileQueryInfoFlags.NONE, null);
+ FileInfo info;
+ while ((info = en.next_file(null)) != null) {
+ string? mime = info.get_content_type();
+ if (info.get_file_type() == FileType.REGULAR && mime != null && mime.has_prefix("image/")) count++;
+ }
+ return count;
+ }
+
+ private static void update_legacy_manifest(string dir, string basename) throws Error {
+ string path = Path.build_filename(dir, "pack.json");
+ if (!FileUtils.test(path, FileTest.IS_REGULAR)) return;
+ var parser = new Json.Parser();
+ parser.load_from_file(path);
+ var root = parser.get_root();
+ if (root == null || root.get_node_type() != Json.NodeType.OBJECT) return;
+ var obj = root.get_object();
+ if (!obj.has_member("images") || obj.get_member("images").get_node_type() != Json.NodeType.ARRAY) return;
+ var images = obj.get_array_member("images");
+ for (uint i = images.get_length(); i > 0; i--) {
+ var node = images.get_element(i - 1);
+ if (node.get_node_type() == Json.NodeType.OBJECT &&
+ node.get_object().has_member("file") &&
+ node.get_object().get_string_member("file") == basename)
+ images.remove_element(i - 1);
+ }
+ var generator = new Json.Generator();
+ generator.set_root(root);
+ generator.to_file(path);
+ }
+
+ public static void delete_pack(WallpaperCollectionInfo collection) throws Error {
+ if (!collection.can_delete_now())
+ throw new IOError.PERMISSION_DENIED("Protected wallpaper collection");
+ remove_tree(File.new_for_path(collection.dir));
+ File.new_for_path(collection.registry_path).delete(null);
+ }
+
+ public static bool needs_background_fallback(WallpaperCollectionInfo collection, string active_uri) {
+ return active_uri != "" && collection.contains_uri(active_uri);
+ }
+
+ // Returns true when deleting the final image also removed the empty pack.
+ public static bool delete_image(WallpaperCollectionInfo collection, string uri) throws Error {
+ if (!collection.can_delete_now() || !collection.contains_uri(uri))
+ throw new IOError.PERMISSION_DENIED("Protected wallpaper image");
+ var image = File.new_for_uri(uri);
+ string? path = image.get_path();
+ string? basename = image.get_basename();
+ if (path == null || basename == null || image.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) != FileType.REGULAR)
+ throw new IOError.INVALID_ARGUMENT("Wallpaper image is not a regular file");
+ image.delete(null);
+ int dot = basename.last_index_of(".");
+ if (dot > 0) {
+ var sidecar = File.new_for_path(Path.build_filename(collection.dir, basename.substring(0, dot) + ".json"));
+ if (sidecar.query_exists(null)) sidecar.delete(null);
+ }
+ update_legacy_manifest(collection.dir, basename);
+ if (image_count(collection.dir) == 0) {
+ delete_pack(collection);
+ return true;
+ }
+ return false;
+ }
}
}
diff --git a/src/core/wallpaper_manager.vala b/src/core/wallpaper_manager.vala
index e147f67..a364d80 100644
--- a/src/core/wallpaper_manager.vala
+++ b/src/core/wallpaper_manager.vala
@@ -17,6 +17,19 @@ namespace Singularity {
private Mutex _mutex = Mutex ();
private WallpaperRotator? rotator = null;
+ // Attribution metadata for the current wallpaper. Mirrors the
+ // dev.sinty.desktop gschema keys background-attribution-title
+ // and background-attribution-author. Updated by reload() from
+ // the schema; both empty means the Background overlay widget
+ // should hide itself. WallpaperManager fires wallpaper_changed
+ // whenever these change, so a wallpaper-set call that clears
+ // the keys (local pack pick, drag-drop, reset) re-paints the
+ // overlay to hide it, and a future call site that sets them
+ // alongside the URI (the OCS apply-this-image flow) re-paints
+ // to show them.
+ public string attribution_title { get; private set; default = ""; }
+ public string attribution_author { get; private set; default = ""; }
+
public signal void wallpaper_changed();
public static WallpaperManager get_default() {
@@ -26,11 +39,60 @@ namespace Singularity {
return _instance;
}
+ // Clicking through several pack thumbnails quickly (confirmed live,
+ // O6N, 2026-09-10: three "Wallpaper loaded" reloads inside ~2s)
+ // fires one full decode-at-display-resolution + GPU texture upload
+ // per click, each on its own background thread. reload()'s
+ // _load_serial/_mutex guard only discards a STALE thread's finished
+ // RESULT -- it does nothing to stop several of those decode+upload
+ // operations from actually running concurrently before being
+ // discarded. On this hardware that is a real hazard, not a
+ // theoretical one: the Sky1/Mali GPU driver stack already has
+ // documented fragility under concurrent GPU work (Panthor crashes,
+ // labwc races). The live reproduction of this exact bug ended in
+ // "Gdk-Message: Lost connection to Wayland compositor." with no
+ // coredump -- a clean Wayland protocol-level disconnect, not a
+ // catchable Vala exception, consistent with the compositor itself
+ // rejecting the client under GPU/surface contention.
+ //
+ // Debounce the SIGNAL-driven reload path so a burst of rapid clicks
+ // coalesces into a single decode+upload after the clicking settles,
+ // rather than racing several. 200ms is imperceptible for the
+ // common single-click case (satisfies "it should refresh
+ // immediately") while eliminating the overlap for a rapid burst.
+ // The constructor's initial reload() stays IMMEDIATE and
+ // undebounced -- startup should show the current wallpaper without
+ // an artificial delay, and there is no burst to coalesce yet.
+ private uint reload_debounce_source = 0;
+
+ private void schedule_reload() {
+ if (reload_debounce_source != 0) Source.remove(reload_debounce_source);
+ reload_debounce_source = Timeout.add(200, () => {
+ reload_debounce_source = 0;
+ reload();
+ return false;
+ });
+ }
+
private WallpaperManager() {
settings = new GLib.Settings("dev.sinty.desktop");
settings.changed["background-picture-uri"].connect(() => {
- reload();
+ schedule_reload();
});
+ // Attribution keys are subscribed independently so they can
+ // move without the URI changing (the future apply-OCS-item
+ // flow will set attribution without re-pointing the wallpaper
+ // file if the URI is already current). Guarded with
+ // schema.has_key() so a binary running against an older
+ // schema (no attribution keys defined yet) does not critical
+ // on missing-key connect.
+ SettingsSchema? schema = settings.settings_schema;
+ if (schema != null) {
+ if (schema.has_key("background-attribution-title"))
+ settings.changed["background-attribution-title"].connect(() => schedule_reload());
+ if (schema.has_key("background-attribution-author"))
+ settings.changed["background-attribution-author"].connect(() => schedule_reload());
+ }
reload();
}
@@ -49,6 +111,38 @@ namespace Singularity {
}
public void reload() {
+ // Read attribution keys defensively: a schema that doesn't
+ // have them yet (older deploy, ad-hoc bisect, dev mode) must
+ // not abort here -- "" is the right empty value, and the
+ // overlay widget treats both-empty as "hide".
+ SettingsSchema? schema = settings.settings_schema;
+ string new_title = "";
+ string new_author = "";
+ if (schema != null) {
+ if (schema.has_key("background-attribution-title"))
+ new_title = settings.get_string("background-attribution-title");
+ if (schema.has_key("background-attribution-author"))
+ new_author = settings.get_string("background-attribution-author");
+ }
+ // Prefer the current image's normalized sidecar. In particular,
+ // Openverse's legally valid plain-text credit must not be parsed
+ // as HTML again by the overlay.
+ string metadata_uri = settings.get_string("background-picture-uri");
+ string? metadata_path = metadata_uri != "" ? File.new_for_uri(metadata_uri).get_path() : null;
+ var metadata = WallpaperSidecar.read(metadata_path ?? "");
+ if (metadata.valid) {
+ new_title = metadata.title;
+ new_author = metadata.author;
+ } else {
+ new_title = WallpaperSidecar.plain_text(new_title);
+ new_author = WallpaperSidecar.plain_text(new_author);
+ }
+ bool attribution_changed =
+ new_title != attribution_title ||
+ new_author != attribution_author;
+ attribution_title = new_title;
+ attribution_author = new_author;
+
string custom_uri = settings.get_string("background-picture-uri");
string? path = resolve_path(custom_uri);
if (path == null) {
@@ -81,7 +175,16 @@ namespace Singularity {
}
}
if (path != null) {
- if (path == _cached_path) return;
+ if (path == _cached_path) {
+ // Wallpaper file unchanged; if only the attribution
+ // metadata moved (URI stays the same but the keys
+ // were updated), the overlay widget still needs to
+ // repaint, so fire wallpaper_changed(). Otherwise
+ // return -- the texture reload below is what fires
+ // the signal for a true wallpaper change.
+ if (attribution_changed) wallpaper_changed();
+ return;
+ }
_cached_path = path;
wallpaper_path = path;
@@ -234,6 +337,77 @@ namespace Singularity {
}
}
+ // Sample the average luminance of an arbitrary rectangular
+ // sub-region of the cached display pixbuf. Used by the
+ // attribution overlay (Background.vala) to pick light or dark
+ // text the same way panel.vala does for the top band, but
+ // sampling the corner rect the overlay occupies instead of the
+ // top strip the panel covers.
+ //
+ // Returns -1.0 if the display pixbuf is not yet loaded or the
+ // rect is fully out of range. Callers compare against
+ // topbar_lum_threshold (0.72, panel.vala) and pick light text
+ // when luminance > threshold, matching the .light-bg CSS class
+ // the panel uses for the same decision.
+ //
+ // Coordinates are in DISPLAY pixbuf pixels (the medium-resolution
+ // texture the panel already samples), not the on-screen output
+ // size. The pixbuf aspect ratio matches the screen aspect, so
+ // a corner in screen-pixel units maps to a corner in pixbuf
+ // pixels at the same proportional position -- callers pass
+ // (x, y, w, h) directly. The rect is clamped into the pixbuf
+ // bounds so a corner that's partially off-screen at the time
+ // the overlay measures still gets a meaningful sample.
+ // Fractional-coordinate variant of corner_luminance. The pixbuf
+ // aspect ratio matches the screen aspect ratio (medium_texture
+ // is built from_file_at_scale preserving aspect), so a
+ // fractional rect (0..1, 0..1) samples the same proportional
+ // position of the screen. Callers don't need to know the
+ // cached pixbuf's pixel size, only where on the screen they
+ // want to sample.
+ public double corner_luminance_frac(double fx, double fy, double fw, double fh) {
+ var pb = _display_pixbuf;
+ if (pb == null) return -1.0;
+ int pw = pb.get_width();
+ int ph = pb.get_height();
+ int x = (int) Math.round(fx * pw);
+ int y = (int) Math.round(fy * ph);
+ int w = (int) Math.round(fw * pw);
+ int h = (int) Math.round(fh * ph);
+ return corner_luminance(x, y, w, h);
+ }
+
+ public double corner_luminance(int x, int y, int w, int h) {
+ var pb = _display_pixbuf;
+ if (pb == null) return -1.0;
+ if (pb.get_bits_per_sample() != 8) return -1.0;
+ int channels = pb.get_n_channels();
+ if (channels < 3) return -1.0;
+ int pw = pb.get_width();
+ int ph = pb.get_height();
+ x = int.max(0, int.min(x, pw - 1));
+ y = int.max(0, int.min(y, ph - 1));
+ w = int.max(1, int.min(w, pw - x));
+ h = int.max(1, int.min(h, ph - y));
+ int rowstride = pb.get_rowstride();
+ uint8[] data = pb.get_pixels_with_length();
+ int n = data.length;
+ double total = 0.0;
+ int count = 0;
+ for (int yy = y; yy < y + h; yy++) {
+ for (int xx = x; xx < x + w; xx++) {
+ int idx = yy * rowstride + xx * channels;
+ if (idx + 2 >= n) continue;
+ double r = data[idx] / 255.0;
+ double g = data[idx + 1] / 255.0;
+ double b = data[idx + 2] / 255.0;
+ total += 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ count++;
+ }
+ }
+ return count > 0 ? total / count : -1.0;
+ }
+
private static Pixbuf? ensure_alpha(Pixbuf? pb) {
if (pb == null) return null;
if (pb.get_has_alpha()) return pb;
diff --git a/src/core/wallpaper_ocs.vala b/src/core/wallpaper_ocs.vala
new file mode 100644
index 0000000..b2a9cf3
--- /dev/null
+++ b/src/core/wallpaper_ocs.vala
@@ -0,0 +1,565 @@
+using GLib;
+using Gee;
+
+namespace Singularity {
+ public errordomain WallpaperOcsError { INVALID }
+ public class WallpaperOcsChoice : Object {
+ public string id;
+ public string name;
+ public WallpaperOcsChoice(string id, string name) { this.id = id; this.name = name; }
+ }
+ public class WallpaperItem : Object {
+ public string provider_id = "";
+ public string id = "";
+ public string name = "";
+ public string author = "";
+ public string license = "";
+ public string preview = "";
+ public string full_res_url = "";
+ public string attribution = "";
+ public string page_url = "";
+ public string creator_url = "";
+ public string license_url = "";
+ public int width = 0;
+ public int height = 0;
+ // Tags emitted per item by the OCS browse response (JSON array of
+ // plain strings, possibly empty). Parsed leniently: absent field or
+ // explicit empty array both become an empty list, matching how the
+ // other optional string fields default to "". A value that is not
+ // an array of strings is a parse error, consistent with the other
+ // shape checks in this class.
+ public string[] tags = {};
+ // Bing-only fields. Defaults keep the existing OCS shape unchanged:
+ // every OCS item has an empty thumbnail_path (the Soup thumbnail
+ // path uses item.preview, a remote URL), is not pinned, and has no
+ // market. The browser treats these as additive, not load-bearing
+ // for OCS items.
+ public string thumbnail_path = "";
+ public bool pinned = false;
+ public string market = "";
+ public string archive_date = "";
+ public string bing_image_id = "";
+ // Canonical identity. For OCS this is "provider:numeric_id"; for Bing
+ // it is "provider:market:Bing-image-id", falling back to archive date
+ // for metadata written by older helpers. Keeping `key` stable
+ // across both item kinds means add_card / filter_cards / the imports
+ // map do not need a parallel data path.
+ public string key { owned get { return provider_id + ":" + id; } }
+ }
+ // JSON from the helper is untrusted. Check types before Json-GLib getters,
+ // which otherwise emit criticals (fatal in the GLib.Test harness).
+ public class WallpaperOcs : Object {
+ private const int TAG_CHARACTER_LIMIT = 64;
+
+ internal static Json.Object object_node(Json.Node? node) throws Error {
+ if (node == null || node.get_node_type() != Json.NodeType.OBJECT)
+ throw new WallpaperOcsError.INVALID("Expected a JSON object");
+ return node.get_object();
+ }
+ internal static Json.Object document(string data, bool schema = true) throws Error {
+ var parser = new Json.Parser();
+ parser.load_from_data(data);
+ var obj = object_node(parser.get_root());
+ if (schema) {
+ var node = obj.get_member("schema");
+ if (node == null || node.get_value_type() != typeof(int64) || node.get_int() != 1)
+ throw new WallpaperOcsError.INVALID("Unsupported OCS response schema");
+ }
+ return obj;
+ }
+ internal static string text(Json.Object obj, string field, bool required = true) throws Error {
+ var node = obj.get_member(field);
+ if (node == null || node.is_null()) {
+ if (!required) return "";
+ throw new WallpaperOcsError.INVALID("Missing OCS field: " + field);
+ }
+ if (node.get_value_type() != typeof(string))
+ throw new WallpaperOcsError.INVALID("Invalid OCS field: " + field);
+ string value = node.get_string();
+ if (required && value.strip() == "")
+ throw new WallpaperOcsError.INVALID("Empty OCS field: " + field);
+ return value;
+ }
+ internal static Json.Array array(Json.Object obj, string field) throws Error {
+ var node = obj.get_member(field);
+ if (node == null || node.get_node_type() != Json.NodeType.ARRAY)
+ throw new WallpaperOcsError.INVALID("Invalid OCS list: " + field);
+ return node.get_array();
+ }
+ // Tags are emitted as a JSON array of strings. Absent field or empty
+ // array both collapse to an empty list; anything else (non-array, or
+ // any non-string element) is rejected so a malformed response cannot
+ // silently degrade the filter UI.
+ internal static string[] tag_array(Json.Object obj, string field) throws Error {
+ var node = obj.get_member(field);
+ if (node == null || node.is_null()) return {};
+ if (node.get_node_type() != Json.NodeType.ARRAY)
+ throw new WallpaperOcsError.INVALID("Invalid OCS list: " + field);
+ var arr = node.get_array();
+ var result = new Gee.ArrayList();
+ foreach (var element in arr.get_elements()) {
+ if (element == null || element.get_value_type() != typeof(string))
+ throw new WallpaperOcsError.INVALID("Invalid OCS tag entry: " + field);
+ var sanitized = new StringBuilder();
+ int index = 0;
+ int characters = 0;
+ unichar c = 0;
+ string raw = element.get_string();
+ while (characters < TAG_CHARACTER_LIMIT && raw.get_next_char(ref index, out c)) {
+ var type = c.type();
+ if (type == UnicodeType.FORMAT ||
+ (type == UnicodeType.CONTROL && !c.isspace())) continue;
+ sanitized.append_unichar(c);
+ characters++;
+ }
+ string t = sanitized.str.strip();
+ if (t != "" && !result.contains(t)) result.add(t);
+ }
+ return result.to_array();
+ }
+ internal static int optional_int(Json.Object obj, string field) throws Error {
+ var node = obj.get_member(field);
+ if (node == null || node.is_null()) return 0;
+ if (node.get_value_type() != typeof(int64) || node.get_int() < 0 || node.get_int() > int.MAX)
+ throw new WallpaperOcsError.INVALID("Invalid OCS field: " + field);
+ return (int) node.get_int();
+ }
+ internal static bool numeric_id(string id) {
+ if (id.length == 0) return false;
+ foreach (char c in id.to_utf8()) if (c < '0' || c > '9') return false;
+ return true;
+ }
+ internal static bool provider_id(string id) {
+ return id == "pling" || id == "opendesktop" || id == "kde-look" || id == "gnome-look";
+ }
+ public static ArrayList providers(string data) throws Error {
+ var obj = object_node(document(data).get_member("providers"));
+ var result = new ArrayList();
+ foreach (string id in obj.get_members()) {
+ if (!provider_id(id)) throw new WallpaperOcsError.INVALID("Unknown OCS provider: " + id);
+ text(object_node(obj.get_member(id)), "base");
+ // opendesktop.org is the former name of pling.com. Both API
+ // hosts currently expose the same catalog, so showing both
+ // only duplicates every result. Keep accepting the legacy id
+ // for existing provenance, but expose the current Pling
+ // service once in the provider picker.
+ if (id == "opendesktop") continue;
+ result.add(new WallpaperOcsChoice(id, id));
+ }
+ result.sort((a, b) => strcmp(a.id, b.id));
+ return result;
+ }
+ public static ArrayList categories(string data, string provider) throws Error {
+ var entries = array(document(data), "entries");
+ var seen = new HashSet();
+ var result = new ArrayList();
+ foreach (var node in entries.get_elements()) {
+ var entry = object_node(node);
+ string reference = text(entry, "ref");
+ string network = reference.split(":")[0];
+ if (!reference.has_prefix(network + ":"))
+ throw new WallpaperOcsError.INVALID("Invalid OCS category reference");
+ if (provider == "ocs" ? (!provider_id(network) || network == "opendesktop") : network != provider) continue;
+ string id = reference.substring(network.length + 1);
+ if (!numeric_id(id)) throw new WallpaperOcsError.INVALID("Invalid OCS category identity");
+ var usable = entry.get_member("usable");
+ if (usable == null || usable.get_value_type() != typeof(bool))
+ throw new WallpaperOcsError.INVALID("Invalid OCS category usability");
+ // The UI exposes all OCS networks as one synthetic "ocs"
+ // provider. Preserve the real network in that provider's
+ // choice id so its browse call can address the helper's
+ // actual provider grammar. The helper deliberately does not
+ // accept "ocs" as a provider name.
+ string choice_id = provider == "ocs" ? reference : id;
+ if (!usable.get_boolean() || !seen.add(choice_id)) continue;
+ string name = text(entry, "display_name", false);
+ if (name == "") name = text(entry, "name");
+ result.add(new WallpaperOcsChoice(choice_id, name));
+ }
+ result.sort((a, b) => a.name.collate(b.name));
+ return result;
+ }
+ public static ArrayList items(string data, string provider, string category) throws Error {
+ var obj = document(data);
+ if (text(obj, "provider") != provider || text(obj, "category") != category)
+ throw new WallpaperOcsError.INVALID("OCS response does not match the requested category");
+ var result = new ArrayList();
+ var seen = new HashSet();
+ foreach (var node in array(obj, "items").get_elements()) {
+ var entry = object_node(node);
+ var item = new WallpaperItem();
+ item.provider_id = text(entry, "provider");
+ item.id = text(entry, "id");
+ if ((provider != "ocs" && item.provider_id != provider) || !provider_id(item.provider_id) || !numeric_id(item.id))
+ throw new WallpaperOcsError.INVALID("Invalid OCS item identity");
+ item.name = text(entry, "name");
+ item.author = text(entry, "author", false);
+ item.license = text(entry, "license", false);
+ item.preview = text(entry, "preview", false);
+ item.page_url = text(entry, "detailpage", false);
+ var download = entry.get_member("download");
+ if (download != null && !download.is_null())
+ item.full_res_url = text(object_node(download), "url", false);
+ item.tags = tag_array(entry, "tags");
+ if (seen.add(item.key)) result.add(item);
+ }
+ return result;
+ }
+ }
+ // Bing is not a JSON-over-OCS feed; it talks to a different helper
+ // (ncz-wallpaper-bing) with its own command grammar. The browser treats
+ // it as a fifth pseudo-provider in the provider row but its command and
+ // response shapes are kept here, out of WallpaperOcs.providers(), so the
+ // OCS parser remains strictly about OCS data. WallpaperBing shares
+ // WallpaperOcsChoice so the category chip row can render markets the same
+ // way it renders OCS categories, and shares WallpaperItem so the rest
+ // of the browser (add_card, filter_cards, thumbnails) keeps a single
+ // code path. The Bing-only fields on WallpaperItem (thumbnail_path,
+ // pinned, market) default to empty/false for OCS items and are populated
+ // by items() below.
+ public class WallpaperBing : Object {
+ // The synthetic provider id used by every Bing item and the browser's
+ // SelectionRow. Hard-coded so the same string shows up in tests, the
+ // browser, and any future call site that needs to recognise Bing.
+ public const string PROVIDER_ID = "bing";
+ // The pseudo-market id for Bing's de-duplicated combined view.
+ //
+ // Bing serves the SAME photograph to several regional markets on a
+ // given day, so a gallery that merges one listing per market shows
+ // the same picture many times over -- 213 cards for 41 distinct
+ // photographs, measured on an O6N with all 14 markets enabled. The
+ // helper already maintains a content-hashed (sha256, not filename or
+ // date) de-duplicated view for the rotator; `list --consolidated`
+ // exposes it to a browser as one row per unique photograph.
+ //
+ // `ncz-wallpaper-bing markets` advertises this id as an extra first
+ // line when the helper is serving the combined, de-duplicated view.
+ //
+ // Before 2026-09-13 the bing-markets config file was a FETCH
+ // filter, and the helper advertised this id only when that file
+ // held the "all" sentinel. As of 2026-09-13 (desktop_page.vala's
+ // BING_MARKETS_ID_ALL / Bing Preferred Region picker) the file's
+ // MEANING changed: the rotator now always fetches and combines
+ // every market regardless of file content, and the file only
+ // names a dedup tie-break preference -- so the matching
+ // cix-installer change is for the helper to advertise this id
+ // UNCONDITIONALLY, not gated on the file's content at all.
+ //
+ // Presence of this id in the helper's own answer is still the
+ // contract (this side never re-derives the mode from the file),
+ // but that means BingWallpaperProvider.choices() in
+ // wallpaper_provider.vala is only ever correct once the deployed
+ // `ncz-wallpaper-bing` binary matches this new contract. A shell
+ // build that ships the "Preferred Region" picker (which now
+ // freely writes a single specific market code -- see
+ // write_bing_markets_codes() in desktop_page.vala) against an
+ // OLDER helper that still treats a non-"all" file as a fetch
+ // restriction will silently narrow both the rotator AND this
+ // provider's OCS browsing results down to one market. Verify the
+ // helper's contract on the target host before assuming a Bing
+ // browsing regression is a bug in this file.
+ public const string CONSOLIDATED_ID = "consolidated";
+ // `ncz-wallpaper-bing markets` prints TSV, NOT JSON: one
+ // "\t" per line. The category chip row
+ // expects an ArrayList just like the OCS
+ // categories() does, so we parse the TSV into the same shape.
+ // Tolerates trailing whitespace, blank lines, and lines with no tab
+ // (those are skipped, not treated as errors -- the helper's real
+ // output is well-formed, but the parser is the safety belt).
+ public static ArrayList markets(string data) throws Error {
+ var result = new ArrayList();
+ var seen = new HashSet();
+ foreach (var raw in data.split("\n")) {
+ string line = raw.strip();
+ if (line == "") continue;
+ int tab = line.index_of("\t");
+ if (tab < 0) continue;
+ string id = line.substring(0, tab).strip();
+ string name = line.substring(tab + 1).strip();
+ if (id == "" || name == "") continue;
+ if (!seen.add(id)) continue;
+ result.add(new WallpaperOcsChoice(id, name));
+ }
+ result.sort((a, b) => a.name.collate(b.name));
+ return result;
+ }
+ // If markets() found the combined pseudo-market, return a list holding
+ // only it; otherwise null, meaning "browse the markets as given".
+ //
+ // The combined view is a view OVER every market, not one more market
+ // beside them, and the browser crawls one listing per choice and
+ // merges everything into a single grid -- so offering both would put
+ // the de-duplicated set and the raw per-market sets in the same grid
+ // and restore precisely the duplication the combined view exists to
+ // remove. Split out of the provider so the rule is testable without
+ // spawning the helper.
+ public static ArrayList? combined_view(ArrayList choices) {
+ foreach (var choice in choices) {
+ if (choice.id != CONSOLIDATED_ID) continue;
+ var only = new ArrayList();
+ only.add(choice);
+ return only;
+ }
+ return null;
+ }
+ // `ncz-wallpaper-bing list ` returns a JSON ARRAY (no
+ // schema/items wrapper, unlike the OCS helper). Each element carries:
+ // provider, date, market, path, caption, copyright,
+ // thumbnail_path, pinned
+ // Parse the array into the shared WallpaperItem shape. `id` on the
+ // item is set to ":" so the existing key=
+ // "provider:id" formula produces a unique, stable identity per Bing
+ // archived image. Tags: Bing has no per-image tags; the field stays
+ // empty so filter_cards does not need to special-case anything.
+ public static ArrayList items(string data) throws Error {
+ var parser = new Json.Parser();
+ parser.load_from_data(data);
+ var root = parser.get_root();
+ if (root == null || root.get_node_type() != Json.NodeType.ARRAY)
+ throw new WallpaperOcsError.INVALID("Expected a Bing list array");
+ var arr = root.get_array();
+ var result = new ArrayList();
+ var seen = new HashSet();
+ foreach (var node in arr.get_elements()) {
+ if (node == null || node.get_node_type() != Json.NodeType.OBJECT)
+ throw new WallpaperOcsError.INVALID("Invalid Bing list entry");
+ var entry = node.get_object();
+ var item = new WallpaperItem();
+ item.provider_id = PROVIDER_ID;
+ // Provider field is required and must equal "bing"; this
+ // catches a helper that ever emits mixed provider types in
+ // the same list.
+ if (WallpaperOcs.text(entry, "provider") != PROVIDER_ID)
+ throw new WallpaperOcsError.INVALID("Bing list entry has unexpected provider");
+ item.market = WallpaperOcs.text(entry, "market");
+ item.archive_date = WallpaperOcs.text(entry, "date");
+ item.bing_image_id = WallpaperOcs.text(entry, "image_id", false);
+ item.name = WallpaperOcs.text(entry, "caption", false);
+ // Composite id keeps item.key unique across markets; this id
+ // is purely the identity for add_card / the imports map.
+ item.id = item.market + ":" + (item.bing_image_id != "" ? item.bing_image_id : item.archive_date);
+ item.author = WallpaperOcs.text(entry, "copyright", false);
+ item.license = ""; // Bing does not emit a license field; honest default.
+ item.preview = ""; // No remote preview URL for Bing -- the
+ // thumbnail_path is loaded locally below.
+ item.thumbnail_path = WallpaperOcs.text(entry, "thumbnail_path", false);
+ var pin = entry.get_member("pinned");
+ if (pin == null || pin.get_value_type() != typeof(bool))
+ throw new WallpaperOcsError.INVALID("Invalid Bing pinned field");
+ item.pinned = pin.get_boolean();
+ // Empty tags stays empty; do NOT synthesise a market-as-tag
+ // here (operator-confirmed design decision: a market name
+ // is not a wallpaper tag, it is a filter axis via the chip
+ // row, which already uses categories).
+ if (seen.add(item.key)) result.add(item);
+ }
+ return result;
+ }
+ }
+ // Openverse is a photo search API, not an OCS network. Its helper emits
+ // a normalized envelope while retaining per-image licensing and links.
+ public class WallpaperOpenverse : Object {
+ public static ArrayList items(string data, string expected_provider = "") throws Error {
+ var obj = WallpaperOcs.document(data);
+ var result = new ArrayList();
+ var seen = new HashSet();
+ foreach (var node in WallpaperOcs.array(obj, "items").get_elements()) {
+ var entry = WallpaperOcs.object_node(node);
+ var item = new WallpaperItem();
+ item.provider_id = WallpaperOcs.text(entry, "provider");
+ item.id = WallpaperOcs.text(entry, "id");
+ if ((item.provider_id != "openverse" && item.provider_id != "unsplash") ||
+ (expected_provider != "" && item.provider_id != expected_provider) ||
+ (item.provider_id == "openverse" && !Uuid.string_is_valid(item.id)) ||
+ (item.provider_id == "unsplash" && (item.id == "" || item.id.length > 64 ||
+ new Regex("[^A-Za-z0-9_-]").match(item.id))))
+ throw new WallpaperOcsError.INVALID("Invalid stock photo identity");
+ item.name = WallpaperOcs.text(entry, "name", false);
+ item.author = WallpaperOcs.text(entry, "author", false);
+ item.preview = WallpaperOcs.text(entry, "preview");
+ item.full_res_url = WallpaperOcs.text(entry, "url", false);
+ item.license = WallpaperOcs.text(entry, "license") + " " + WallpaperOcs.text(entry, "license_version", false);
+ item.attribution = WallpaperOcs.text(entry, "attribution", false);
+ item.page_url = WallpaperOcs.text(entry, "page_url", false);
+ item.creator_url = WallpaperOcs.text(entry, "creator_url", false);
+ item.license_url = WallpaperOcs.text(entry, "license_url", false);
+ item.tags = WallpaperOcs.tag_array(entry, "tags");
+ item.width = WallpaperOcs.optional_int(entry, "width");
+ item.height = WallpaperOcs.optional_int(entry, "height");
+ if (seen.add(item.key)) result.add(item);
+ }
+ return result;
+ }
+ }
+ // The backend owns disk writes. This model tracks an active import and
+ // reconciles completed imports against its real registry/provenance files.
+ //
+ // OCS imports accumulate into ONE shared user-side collection ("Imported
+ // from OCS", Id=imported-ocs). Per-image provenance is in per-image
+ // sidecar JSON files (.json) sitting next to each .jpg in
+ // the collection's Dir. discover() scans sidecars to know what is already
+ // imported; complete() validates that each per-image response actually
+ // landed on disk and that the sidecar's (provider, ocs_id) matches the
+ // key the browser tried to import -- never mark "added" without real
+ // files landing under the right identity.
+ public class WallpaperOcsImports : Object {
+ private string active = "";
+ private HashSet added = new HashSet();
+ public bool busy { get { return active != ""; } }
+ public bool begin(string key) {
+ if (busy || added.contains(key)) return false;
+ active = key;
+ return true;
+ }
+ public void fail(string key) { if (active == key) active = ""; }
+ public bool is_added(string key) { return added.contains(key); }
+ // Scan the sidecar files in `dir` (one per imported image) and return
+ // every "provider:id" pair represented by a well-formed sidecar whose
+ // image file also exists on disk. Old-shape directories from earlier
+ // one-pack-per-import testing have no sidecars and silently contribute
+ // nothing, which is what discover() wants.
+ private static Gee.ArrayList sidecar_keys(string dir) {
+ var result = new Gee.ArrayList();
+ if (!FileUtils.test(dir, FileTest.IS_DIR)) return result;
+ string data;
+ var listing = Dir.open(dir);
+ string? name;
+ while ((name = listing.read_name()) != null) {
+ if (!name.has_suffix(".json")) continue;
+ string sidecar_path = Path.build_filename(dir, name);
+ if (!FileUtils.test(sidecar_path, FileTest.IS_REGULAR)) continue;
+ string image_basename = name.substring(0, name.length - ".json".length);
+ if (!FileUtils.test(Path.build_filename(dir, image_basename + ".jpg"), FileTest.IS_REGULAR) &&
+ !FileUtils.test(Path.build_filename(dir, image_basename + ".png"), FileTest.IS_REGULAR) &&
+ !FileUtils.test(Path.build_filename(dir, image_basename + ".webp"), FileTest.IS_REGULAR))
+ continue;
+ try {
+ FileUtils.get_contents(sidecar_path, out data);
+ var doc = WallpaperOcs.document(data, false);
+ if (WallpaperOcs.text(doc, "provider", false) == "openverse") {
+ string identity = WallpaperOcs.text(doc, "id");
+ if (Uuid.string_is_valid(identity)) result.add("openverse:" + identity);
+ continue;
+ }
+ if (WallpaperOcs.text(doc, "provider", false) == "unsplash") {
+ string identity = WallpaperOcs.text(doc, "id");
+ if (identity != "" && identity.length <= 64 && !new Regex("[^A-Za-z0-9_-]").match(identity))
+ result.add("unsplash:" + identity);
+ continue;
+ }
+ if (WallpaperOcs.text(doc, "origin") != "ocs") continue;
+ string provider = WallpaperOcs.text(doc, "provider");
+ string id = WallpaperOcs.text(WallpaperOcs.object_node(doc.get_member("source")), "ocs_id");
+ if (!WallpaperOcs.provider_id(provider) || !WallpaperOcs.numeric_id(id)) continue;
+ result.add(provider + ":" + id);
+ } catch (Error e) {
+ /* malformed sidecar is not an import */
+ }
+ }
+ return result;
+ }
+ // Older installed helpers create one directory per import and put the
+ // provenance in pack.json instead of per-image sidecars. Accept that
+ // deployed format while installations transition to the shared pack.
+ private static string legacy_pack_key(string dir) {
+ string path = Path.build_filename(dir, "pack.json");
+ if (!FileUtils.test(path, FileTest.IS_REGULAR)) return "";
+ try {
+ string data;
+ FileUtils.get_contents(path, out data);
+ var doc = WallpaperOcs.document(data, false);
+ if (WallpaperOcs.text(doc, "origin") != "ocs") return "";
+ string provider = WallpaperOcs.text(doc, "provider");
+ string id = WallpaperOcs.text(WallpaperOcs.object_node(doc.get_member("source")), "ocs_id");
+ if (!WallpaperOcs.provider_id(provider) || !WallpaperOcs.numeric_id(id)) return "";
+ foreach (var node in WallpaperOcs.array(doc, "images").get_elements()) {
+ string name = WallpaperOcs.text(WallpaperOcs.object_node(node), "file");
+ if (name == Path.get_basename(name) && name.has_suffix(".jpg") &&
+ FileUtils.test(Path.build_filename(dir, name), FileTest.IS_REGULAR))
+ return provider + ":" + id;
+ }
+ } catch (Error e) {
+ /* malformed legacy metadata is not an import */
+ }
+ return "";
+ }
+ public void discover(ArrayList collections) {
+ added.clear();
+ foreach (var collection in collections) {
+ foreach (string key in sidecar_keys(collection.dir)) added.add(key);
+ string legacy = legacy_pack_key(collection.dir);
+ if (legacy != "") added.add(legacy);
+ }
+ }
+ // The single shared "Imported from OCS" collection: registered once on
+ // first import, identity never changes across imports. The picker
+ // surfaces it the same way as any other pack because the .collection
+ // file lives in the user search roots.
+ private const string IMPORTED_OCS_ID = "imported-ocs";
+ public void complete(string key, string data, string[] roots) throws Error {
+ if (active != key) throw new WallpaperOcsError.INVALID("No matching import is active");
+ var obj = WallpaperOcs.document(data, false);
+ string id = WallpaperOcs.text(obj, "pack_id");
+ string dir = WallpaperOcs.text(obj, "destination");
+ string collection_path = WallpaperOcs.text(obj, "collection");
+ if (!Path.is_absolute(dir) || !FileUtils.test(dir, FileTest.IS_DIR) ||
+ !FileUtils.test(collection_path, FileTest.IS_REGULAR))
+ throw new WallpaperOcsError.INVALID("Import did not produce registered collection files");
+ bool registered = false;
+ foreach (var collection in WallpaperCollections.parse(roots)) {
+ if (collection.id == id && collection.dir == dir) { registered = true; break; }
+ }
+ if (!registered)
+ throw new WallpaperOcsError.INVALID("Imported pack is missing from the collection registry");
+ string provider_id = key.split(":")[0];
+ if (id != IMPORTED_OCS_ID && id != "ocs" && id != provider_id) {
+ string candidate = legacy_pack_key(dir);
+ if (candidate != key)
+ throw new WallpaperOcsError.INVALID("Legacy imported pack provenance does not match the active import key");
+ added.add(key);
+ active = "";
+ return;
+ }
+ // Per-image files: every image in the response must point to a real
+ // sidecar file with a real .jpg next to it, AND the sidecar's
+ // provider:ocs_id must match the import's key. This preserves the
+ // old class's safety property: never mark something added unless
+ // real files actually landed on disk under the right identity.
+ var images = WallpaperOcs.array(obj, "images");
+ if (images.get_length() == 0)
+ throw new WallpaperOcsError.INVALID("Imported pack contains no images");
+ bool found_key = false;
+ foreach (var node in images.get_elements()) {
+ var image = WallpaperOcs.object_node(node);
+ // `file` and `sidecar` are basenames in the response payload,
+ // mirroring the old per-pack `file` field shape; the absolute
+ // path is built against the shared collection's `dir`.
+ string name = WallpaperOcs.text(image, "file");
+ string sidecar = WallpaperOcs.text(image, "sidecar");
+ if (name != Path.get_basename(name) ||
+ !(name.has_suffix(".jpg") || name.has_suffix(".png") || name.has_suffix(".webp")) ||
+ !FileUtils.test(Path.build_filename(dir, name), FileTest.IS_REGULAR))
+ throw new WallpaperOcsError.INVALID("Imported image is missing");
+ if (sidecar != Path.get_basename(sidecar) || !sidecar.has_suffix(".json"))
+ throw new WallpaperOcsError.INVALID("Imported image sidecar path is malformed");
+ if (sidecar != name.substring(0, name.last_index_of(".")) + ".json")
+ throw new WallpaperOcsError.INVALID("Imported image sidecar does not pair with image");
+ string sidecar_path = Path.build_filename(dir, sidecar);
+ if (!FileUtils.test(sidecar_path, FileTest.IS_REGULAR))
+ throw new WallpaperOcsError.INVALID("Imported image sidecar is missing");
+ string sidecar_data;
+ FileUtils.get_contents(sidecar_path, out sidecar_data);
+ var sidecar_doc = WallpaperOcs.document(sidecar_data, false);
+ string provider = WallpaperOcs.text(sidecar_doc, "provider");
+ string ocs_id = (provider == "openverse" || provider == "unsplash") ? WallpaperOcs.text(sidecar_doc, "id") :
+ WallpaperOcs.text(WallpaperOcs.object_node(sidecar_doc.get_member("source")), "ocs_id");
+ string candidate = provider + ":" + ocs_id;
+ if (candidate == key) found_key = true;
+ }
+ if (!found_key)
+ throw new WallpaperOcsError.INVALID("No imported image sidecar matches the active import key");
+ added.add(key);
+ active = "";
+ }
+ }
+}
diff --git a/src/core/wallpaper_provider.vala b/src/core/wallpaper_provider.vala
new file mode 100644
index 0000000..0650644
--- /dev/null
+++ b/src/core/wallpaper_provider.vala
@@ -0,0 +1,259 @@
+using GLib;
+using Gee;
+
+namespace Singularity {
+ public class WallpaperProviderResult : Object {
+ public ArrayList items = new ArrayList();
+ public int page_count = 1;
+ public bool stale = false;
+ public string warning = "";
+ }
+
+ public interface WallpaperProvider : Object {
+ public abstract string id { get; }
+ public abstract string display_name { owned get; }
+ public abstract bool requires_credentials { get; }
+ public abstract bool supports_search { get; }
+ public abstract async ArrayList choices(string category_index,
+ Cancellable? cancel) throws Error;
+ public abstract async WallpaperProviderResult browse(string choice_id, string query,
+ int page, bool force_refresh, Cancellable? cancel) throws Error;
+ public abstract async string import_item(WallpaperItem item, Cancellable? cancel) throws Error;
+ }
+
+ public abstract class WallpaperHelperProvider : Object {
+ protected string helper;
+
+ protected WallpaperHelperProvider(string helper) {
+ this.helper = helper;
+ }
+
+ protected async string command(string[] argv, Cancellable? cancel, uint timeout,
+ bool force_refresh = false, string? input = null) throws Error {
+ var launcher = new SubprocessLauncher(SubprocessFlags.STDIN_PIPE |
+ SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+ if (force_refresh) launcher.setenv("NCZ_WALLPAPER_REFRESH", "1", true);
+ launcher.set_child_setup(() => { Posix.setsid(); });
+ var process = launcher.spawnv(argv);
+ bool timed_out = false;
+ uint timer = Timeout.add_seconds(timeout, () => {
+ timed_out = true;
+ stop_helper(process);
+ return Source.REMOVE;
+ });
+ ulong cancel_handler = 0;
+ if (cancel != null) {
+ cancel_handler = cancel.cancelled.connect(() => stop_helper(process));
+ if (cancel.is_cancelled()) stop_helper(process);
+ }
+ string output;
+ string errors;
+ try {
+ yield process.communicate_utf8_async(input, null, out output, out errors);
+ } catch (Error e) {
+ stop_helper(process);
+ yield process.wait_async(null);
+ throw e;
+ } finally {
+ if (!timed_out) Source.remove(timer);
+ if (cancel_handler != 0) cancel.disconnect(cancel_handler);
+ }
+ if (cancel != null) cancel.set_error_if_cancelled();
+ if (timed_out) throw new IOError.TIMED_OUT("Wallpaper request timed out. Try again.");
+ if (!process.get_successful()) {
+ string detail = errors.strip();
+ if (detail.length > 300) detail = detail.substring(0, 300).make_valid();
+ throw new IOError.FAILED(detail != "" ? detail : "Wallpaper helper failed.");
+ }
+ return output;
+ }
+
+ private static void stop_helper(Subprocess process) {
+ if (process.get_if_exited()) return;
+ string? identifier = process.get_identifier();
+ int pid = 0;
+ if (identifier != null && int.try_parse(identifier, out pid) && pid > 1)
+ Posix.kill((Posix.pid_t) (-pid), Posix.Signal.KILL);
+ process.force_exit();
+ }
+ }
+
+ public class OcsWallpaperProvider : WallpaperHelperProvider, WallpaperProvider {
+ // OCS answers `pagesize` items per request and its own default is 10,
+ // not the ~50 the aggregate crawl was written against. A single
+ // 10-item page per category is a fraction of what a category holds
+ // (pling 300 reports totalitems=1971), so the crawl was returning
+ // roughly 400 wallpapers where the browser's crawl safety cap was
+ // meant to be the binding limit. Ask for the server's maximum page
+ // size: still ONE request per category, so the request
+ // count and the per-category timeout budget are unchanged. The
+ // server rejects anything above 100 with statuscode 400, and the
+ // helper clamps to that.
+ //
+ // REQUIRES a helper that understands --page-size (cix-installer
+ // "fix(wallpaper): let OCS browse ask for a real page size"). An
+ // older /usr/local/bin/ncz-wallpaper-ocs exits with an argparse
+ // "unrecognized arguments" error, which surfaces per category in the
+ // browser's status line -- loudly, not as a silent short result. The
+ // two ship together from one image build (cix-installer
+ // post-install/45-wallpaper-rotator.sh installs the helper), so keep
+ // them in step rather than feature-probing on every category.
+ private const string OCS_PAGE_SIZE = "100";
+ public string id { get { return "ocs"; } }
+ public string display_name { owned get { return "OCS Network"; } }
+ public bool requires_credentials { get { return false; } }
+ public bool supports_search { get { return false; } }
+ public OcsWallpaperProvider() { base("/usr/local/bin/ncz-wallpaper-ocs"); }
+ public async ArrayList choices(string index, Cancellable? cancel) throws Error {
+ return WallpaperOcs.categories(index, id);
+ }
+ public async WallpaperProviderResult browse(string category, string query, int page,
+ bool refresh, Cancellable? cancel) throws Error {
+ string[] identity = category.split(":");
+ if (identity.length != 2 || !WallpaperOcs.provider_id(identity[0]) ||
+ !WallpaperOcs.numeric_id(identity[1]))
+ throw new WallpaperOcsError.INVALID("Invalid aggregate OCS category identity");
+ string network = identity[0];
+ string network_category = identity[1];
+ string data = yield command({helper, "browse", network, network_category,
+ "--pages", "1", "--page-size", OCS_PAGE_SIZE}, cancel, 60, refresh);
+ var result = new WallpaperProviderResult();
+ result.items = WallpaperOcs.items(data, network, network_category);
+ var response = WallpaperOcs.document(data);
+ var failed = response.get_member("failed_networks");
+ if (failed != null && failed.get_node_type() == Json.NodeType.ARRAY && failed.get_array().get_length() > 0)
+ result.warning = "Some OCS networks could not be reached.";
+ var stale = response.get_member("stale");
+ result.stale = stale != null && stale.get_value_type() == typeof(bool) && stale.get_boolean();
+ if (result.stale) result.warning = "Using cached OCS results after refresh failure.";
+ return result;
+ }
+ public async string import_item(WallpaperItem item, Cancellable? cancel) throws Error {
+ return yield command({helper, "import", item.provider_id, item.id}, cancel, 600);
+ }
+ }
+
+ public class BingWallpaperProvider : WallpaperHelperProvider, WallpaperProvider {
+ // Set by choices() from the helper's own answer, never re-derived
+ // from the config file -- see WallpaperBing.CONSOLIDATED_ID. False
+ // until the first choices() call, so the name starts as plain "Bing"
+ // and the browser refreshes the label once the helper has replied.
+ private bool combined = false;
+ public string id { get { return WallpaperBing.PROVIDER_ID; } }
+ // Matches the collection label the cix-installer rotator writes for
+ // the same de-duplicated view ("Bing (Combined, All Markets)"), so
+ // the online browser and the wallpaper theme picker name one thing
+ // one way.
+ public string display_name {
+ owned get { return combined ? "Bing (Combined, All Markets)" : "Bing"; }
+ }
+ public bool requires_credentials { get { return false; } }
+ public bool supports_search { get { return false; } }
+ public BingWallpaperProvider() { base("/usr/local/bin/ncz-wallpaper-bing"); }
+ public async ArrayList choices(string index, Cancellable? cancel) throws Error {
+ var loaded = WallpaperBing.markets(yield command({helper, "markets"}, cancel, 30));
+ // The helper is SUPPOSED to always advertise the combined view
+ // now (it always fetches every market; see configured_markets()
+ // in 45-wallpaper-rotator.sh), so this should unconditionally be
+ // the ONLY browsing axis -- per-market browsing is no longer
+ // reachable through the picker, because the picker no longer
+ // restricts which markets are fetched at all. What the picker
+ // sets today (the Bing Preferred Region SelectionRow in
+ // desktop_page.vala) is a PREFERRED region for dedup
+ // tie-breaking, not a fetch filter, so it should have no
+ // bearing on what choices() returns here.
+ var only = WallpaperBing.combined_view(loaded);
+ combined = only != null;
+ // "Should" above is load-bearing: this is only true once the
+ // deployed ncz-wallpaper-bing binary matches the 2026-09-13
+ // contract change (see the CONSOLIDATED_ID comment in
+ // wallpaper_ocs.vala). A helper that predates that change still
+ // gates the combined view on the bing-markets file literally
+ // holding "all", and the Preferred Region picker now routinely
+ // writes a single specific market code -- so combined coming
+ // back false here on a host where every market was expected is
+ // the signature of that version skew, not a bug in this file.
+ // Surface it instead of silently returning a narrowed per-
+ // market list that looks like "Bing is broken".
+ if (!combined) {
+ warning("wallpaper_provider: ncz-wallpaper-bing did not advertise the consolidated view " +
+ "(got %d raw market choices) -- if the bing-markets file does not hold \"all\", this " +
+ "usually means the deployed helper predates the 2026-09-13 always-combine contract " +
+ "change; see CONSOLIDATED_ID in wallpaper_ocs.vala", loaded.size);
+ }
+ return only ?? loaded;
+ }
+ public async WallpaperProviderResult browse(string market, string query, int page,
+ bool refresh, Cancellable? cancel) throws Error {
+ var result = new WallpaperProviderResult();
+ string selector = market == WallpaperBing.CONSOLIDATED_ID ? "--consolidated" : market;
+ result.items = WallpaperBing.items(yield command({helper, "list", selector}, cancel, 60, refresh));
+ return result;
+ }
+ public async string import_item(WallpaperItem item, Cancellable? cancel) throws Error {
+ throw new IOError.NOT_SUPPORTED("Bing wallpapers are already installed locally.");
+ }
+ }
+
+ public class StockWallpaperProvider : WallpaperHelperProvider, WallpaperProvider {
+ private string provider_id;
+ public string id { get { return provider_id; } }
+ public string display_name { owned get { return provider_id == "openverse" ? "Openverse" : "Unsplash"; } }
+ public bool requires_credentials { get { return provider_id == "unsplash"; } }
+ public bool supports_search { get { return true; } }
+ public StockWallpaperProvider(string id, string helper_path) {
+ base(helper_path);
+ provider_id = id;
+ }
+ public async ArrayList choices(string index, Cancellable? cancel) throws Error {
+ return new ArrayList();
+ }
+ public async WallpaperProviderResult browse(string choice, string query, int page,
+ bool refresh, Cancellable? cancel) throws Error {
+ string[] argv = {helper, "search", query, "--page", page.to_string()};
+ if (refresh) argv += "--refresh";
+ string data = yield command(argv, cancel, 90);
+ var response = WallpaperOcs.document(data);
+ var result = new WallpaperProviderResult();
+ result.items = WallpaperOpenverse.items(data, provider_id);
+ var pages = response.get_member("page_count");
+ if (pages == null || pages.get_value_type() != typeof(int64))
+ throw new WallpaperOcsError.INVALID("Invalid stock photo page count");
+ result.page_count = (int) pages.get_int();
+ var stale = response.get_member("stale");
+ result.stale = stale != null && stale.get_value_type() == typeof(bool) && stale.get_boolean();
+ return result;
+ }
+ public async string import_item(WallpaperItem item, Cancellable? cancel) throws Error {
+ return yield command({helper, "import", item.id}, cancel, 600);
+ }
+ }
+
+ public class WallpaperProviderRegistry : Object {
+ // Release registration policy. Re-enable a provider by adding its id here.
+ public const string[] ACTIVE_PROVIDER_IDS = { "ocs", "bing" };
+ private ArrayList active = new ArrayList();
+ private ArrayList available = new ArrayList();
+
+ public WallpaperProviderRegistry() {
+ available.add(new OcsWallpaperProvider());
+ available.add(new BingWallpaperProvider());
+ available.add(new StockWallpaperProvider("openverse", "/usr/local/bin/ncz-wallpaper-openverse"));
+ available.add(new StockWallpaperProvider("unsplash", "/usr/local/bin/ncz-wallpaper-unsplash"));
+ foreach (string id in ACTIVE_PROVIDER_IDS) {
+ var provider = find_available(id);
+ if (provider != null) active.add(provider);
+ }
+ }
+ public Gee.List get_active() { return active; }
+ public Gee.List get_available() { return available; }
+ public WallpaperProvider? lookup(string id) {
+ foreach (var provider in active) if (provider.id == id) return provider;
+ return null;
+ }
+ private WallpaperProvider? find_available(string id) {
+ foreach (var provider in available) if (provider.id == id) return provider;
+ return null;
+ }
+ }
+}
diff --git a/src/core/wallpaper_sidecar.vala b/src/core/wallpaper_sidecar.vala
new file mode 100644
index 0000000..4b9b3ce
--- /dev/null
+++ b/src/core/wallpaper_sidecar.vala
@@ -0,0 +1,171 @@
+using GLib;
+
+namespace Singularity {
+
+// Pure-data result of reading a wallpaper's sibling .json sidecar.
+// Three flavours drive the contract:
+// * OCS (origin="ocs"): title from image.title, author from artist.name.
+// * Bing (provider="bing"): title from caption, author from copyright.
+// * anything else / missing / malformed: valid=false, both empty. The
+// caller MUST treat valid=false as "clear the attribution overlay"; a
+// stale OCS attribution from a previous wallpaper must never bleed
+// through a plain local photo or a partial sidecar.
+//
+// `valid` is the discriminator; the caller does not need to check whether
+// the JSON was well-formed separately. A sidecar with partial metadata
+// (e.g. only title, no artist) reports valid=true with one field set and
+// the other empty -- the overlay renders what it has.
+public struct WallpaperAttribution {
+ public string title;
+ public string author;
+ public string source;
+ public string page_url;
+ public string license_url;
+ public bool valid;
+}
+
+// Read .json next to `path` (a local image file) and return
+// attribution metadata for the desktop overlay. The contract:
+// * path may be a relative or absolute filesystem path; URI schemes
+// are not supported (the caller resolves file:// URIs to paths first
+// via GLib.File.get_path()).
+// * A missing or non-regular sidecar returns valid=false (the common
+// case for plain local photos).
+// * Each optional field is read defensively -- absent, null,
+// non-string, non-object, or empty-string collapses to "".
+// * A JSON top-level value that is not an object (array, number,
+// bool, null) returns valid=false without crashing.
+// * An unrecognised sidecar returns valid=false.
+//
+// Pure function: no GSettings, no I/O outside the sidecar file. Tests
+// can construct fixture directories and exercise every branch without
+// booting a GTK application.
+public class WallpaperSidecar : GLib.Object {
+ // Already-normalized fields are plain Label text, never markup.
+ public static string display_text(WallpaperAttribution metadata) {
+ if (!metadata.valid) return "";
+ string result = metadata.title;
+ if (metadata.author != "") result += (result != "" ? "\n" : "") + metadata.author;
+ if (metadata.source != "") result += (result != "" ? "\n" : "") + metadata.source;
+ return result;
+ }
+
+ private static string text(Json.Object obj, string field) {
+ var node = obj.get_member(field);
+ return node != null && node.get_value_type() == typeof(string) ? node.get_string() : "";
+ }
+ // Provider metadata is HTML content, never Pango markup. Strip tags
+ // before decoding entities so encoded literal angle brackets survive.
+ public static string plain_text(string text) {
+ try {
+ var tags = new Regex("|?[A-Za-z][^>]*>");
+ string plain = tags.replace_literal(text, -1, 0, "");
+ return plain.replace(" ", " ").replace("©", "©")
+ .replace(""", "\"").replace("'", "'")
+ .replace("'", "'").replace("<", "<")
+ .replace(">", ">").replace("&", "&").strip();
+ } catch (RegexError e) {
+ return text;
+ }
+ }
+ public static WallpaperAttribution read(string path) {
+ var result = WallpaperAttribution() { title = "", author = "", source = "", page_url = "", license_url = "", valid = false };
+ if (path == null || path == "") return result;
+ // Sidecar sits next to the image: e.g.
+ // /var/cache/.../pling-123-01-foo.jpg -> pling-123-01-foo.json
+ string basename = Path.get_basename(path);
+ int dot = basename.last_index_of(".");
+ if (dot <= 0) return result;
+ string sidecar_name = basename.substring(0, dot) + ".json";
+ string sidecar_path = Path.build_filename(Path.get_dirname(path), sidecar_name);
+ if (!FileUtils.test(sidecar_path, FileTest.IS_REGULAR)) return result;
+ string data;
+ try {
+ FileUtils.get_contents(sidecar_path, out data);
+ } catch (Error e) {
+ return result;
+ }
+ var parser = new Json.Parser();
+ try {
+ parser.load_from_data(data);
+ } catch (Error e) {
+ return result;
+ }
+ Json.Node? root = parser.get_root();
+ if (root == null || root.get_node_type() != Json.NodeType.OBJECT) return result;
+ Json.Object obj = root.get_object();
+ // The OCS and Bing helpers use different provenance discriminators.
+ string origin = "";
+ var onode = obj.get_member("origin");
+ if (onode != null && onode.get_value_type() == typeof(string))
+ origin = onode.get_string();
+ if (origin == "ocs") {
+ result.source = "OCS Network";
+ // OCS shape: the image record carries the title; artist is top-level.
+ var inode = obj.get_member("image");
+ if (inode != null && inode.get_node_type() == Json.NodeType.OBJECT) {
+ var img = inode.get_object();
+ var tnode = img.get_member("title");
+ if (tnode != null && tnode.get_value_type() == typeof(string)) {
+ string t = tnode.get_string().strip();
+ if (t != "") result.title = t;
+ }
+ }
+ var anode = obj.get_member("artist");
+ if (anode != null && anode.get_node_type() == Json.NodeType.OBJECT) {
+ var artist = anode.get_object();
+ var nnode = artist.get_member("name");
+ if (nnode != null && nnode.get_value_type() == typeof(string)) {
+ string a = nnode.get_string().strip();
+ if (a != "") result.author = a;
+ }
+ }
+ result.valid = true;
+ } else {
+ string provider = "";
+ var pnode = obj.get_member("provider");
+ if (pnode != null && pnode.get_value_type() == typeof(string))
+ provider = pnode.get_string();
+ if (provider == "openverse") {
+ result.title = text(obj, "name");
+ // The OpenAPI schema explicitly defines attribution as plain
+ // text. Do not strip literal angle brackets from that field.
+ result.author = text(obj, "attribution");
+ if (result.author == "") result.author = text(obj, "author");
+ result.source = "Openverse · " + text(obj, "license") + " " + text(obj, "license_version");
+ result.page_url = text(obj, "page_url");
+ result.license_url = text(obj, "license_url");
+ result.valid = true;
+ return result;
+ }
+ if (provider == "unsplash") {
+ result.title = text(obj, "name");
+ result.author = text(obj, "attribution");
+ if (result.author == "") result.author = text(obj, "author");
+ result.source = "Unsplash · " + text(obj, "license");
+ result.page_url = text(obj, "page_url");
+ result.license_url = text(obj, "license_url");
+ result.valid = true;
+ return result;
+ }
+ if (provider != "bing") return result;
+ result.source = "Bing";
+ // Bing shape: caption -> title, copyright -> author.
+ var cnode = obj.get_member("caption");
+ if (cnode != null && cnode.get_value_type() == typeof(string)) {
+ string c = cnode.get_string().strip();
+ if (c != "") result.title = c;
+ }
+ var crnode = obj.get_member("copyright");
+ if (crnode != null && crnode.get_value_type() == typeof(string)) {
+ string c = crnode.get_string().strip();
+ if (c != "") result.author = c;
+ }
+ result.valid = true;
+ }
+ result.title = plain_text(result.title);
+ result.author = plain_text(result.author);
+ return result;
+ }
+}
+}
diff --git a/src/core/wallpaper_thumbnail_cache.vala b/src/core/wallpaper_thumbnail_cache.vala
new file mode 100644
index 0000000..4b63728
--- /dev/null
+++ b/src/core/wallpaper_thumbnail_cache.vala
@@ -0,0 +1,145 @@
+using GLib;
+using Gee;
+
+namespace Singularity {
+ // Persistent cache for remote OCS preview bytes. Decoding remains the
+ // browser's responsibility so the original image format is preserved.
+ public class WallpaperThumbnailCache : Object {
+ // Enough for a large working set of previews without allowing an OCS
+ // crawl to consume the user's cache directory without bound.
+ public const uint64 BYTE_BUDGET = 200 * 1024 * 1024;
+
+ private bool size_known = false;
+ private uint64 known_size = 0;
+
+ private class CacheFile : Object {
+ public File file;
+ public uint64 size;
+ public uint64 modified;
+
+ public CacheFile(File file, uint64 size, uint64 modified) {
+ this.file = file;
+ this.size = size;
+ this.modified = modified;
+ }
+ }
+
+ public static string directory() {
+ return Path.build_filename(Environment.get_user_cache_dir(),
+ "singularity", "wallpaper-thumbnails");
+ }
+
+ private static string path_for(string url) {
+ string key = Checksum.compute_for_string(ChecksumType.SHA256, url);
+ return Path.build_filename(directory(), key);
+ }
+
+ // Missing, empty and unreadable entries are all ordinary cache misses.
+ public new Bytes? get(string url) {
+ string path = path_for(url);
+ if (!FileUtils.test(path, FileTest.IS_REGULAR)) return null;
+ try {
+ uint8[] data;
+ FileUtils.get_data(path, out data);
+ if (data.length == 0) return null;
+ // mtime is the LRU clock. Failure to touch it does not make an
+ // otherwise valid cache entry unusable.
+ try {
+ File.new_for_path(path).set_attribute_uint64(
+ FileAttribute.TIME_MODIFIED,
+ (uint64) new DateTime.now_utc().to_unix(),
+ FileQueryInfoFlags.NONE, null);
+ } catch (Error e) {}
+ return new Bytes.take((owned) data);
+ } catch (Error e) {
+ message("Discarding unreadable wallpaper thumbnail cache %s: %s",
+ path, e.message);
+ return null;
+ }
+ }
+
+ // Best effort and atomic: cache failures never interrupt browsing.
+ public void put(string url, Bytes data) {
+ if (data.get_size() == 0) return;
+ string dir = directory();
+ if (DirUtils.create_with_parents(dir, 0700) != 0) {
+ message("Could not create wallpaper thumbnail cache directory %s", dir);
+ return;
+ }
+
+ if (!size_known) refresh_size();
+ string path = path_for(url);
+ uint64 replaced_size = file_size(path);
+ string temporary = Path.build_filename(dir,
+ ".thumbnail-" + Uuid.string_random() + ".tmp");
+ try {
+ unowned uint8[] contents = data.get_data();
+ FileUtils.set_data(temporary, contents);
+ if (FileUtils.rename(temporary, path) != 0) {
+ FileUtils.unlink(temporary);
+ message("Could not move wallpaper thumbnail cache %s into place", path);
+ return;
+ }
+ known_size = known_size >= replaced_size
+ ? known_size - replaced_size + data.get_size()
+ : data.get_size();
+ if (known_size > BYTE_BUDGET) evict();
+ } catch (Error e) {
+ FileUtils.unlink(temporary);
+ message("Could not write wallpaper thumbnail cache %s: %s", path, e.message);
+ }
+ }
+
+ private static uint64 file_size(string path) {
+ try {
+ return File.new_for_path(path).query_info(FileAttribute.STANDARD_SIZE,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null).get_size();
+ } catch (Error e) {
+ return 0;
+ }
+ }
+
+ private void refresh_size() {
+ known_size = 0;
+ try {
+ var dir = File.new_for_path(directory());
+ var enumerator = dir.enumerate_children(
+ "standard::type,standard::size", FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ FileInfo info;
+ while ((info = enumerator.next_file(null)) != null)
+ if (info.get_file_type() == FileType.REGULAR)
+ known_size += info.get_size();
+ } catch (Error e) {}
+ size_known = true;
+ }
+
+ private void evict() {
+ var entries = new ArrayList();
+ uint64 total = 0;
+ try {
+ var dir = File.new_for_path(directory());
+ var enumerator = dir.enumerate_children(
+ "standard::name,standard::type,standard::size,time::modified",
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ FileInfo info;
+ while ((info = enumerator.next_file(null)) != null) {
+ if (info.get_file_type() != FileType.REGULAR) continue;
+ var entry = new CacheFile(dir.get_child(info.get_name()),
+ info.get_size(), info.get_attribute_uint64(FileAttribute.TIME_MODIFIED));
+ entries.add(entry);
+ total += entry.size;
+ }
+ entries.sort((a, b) => a.modified < b.modified ? -1
+ : (a.modified > b.modified ? 1 : 0));
+ foreach (var entry in entries) {
+ if (total <= BYTE_BUDGET) break;
+ try {
+ entry.file.delete(null);
+ total -= entry.size;
+ } catch (Error e) {}
+ }
+ } catch (Error e) {}
+ known_size = total;
+ }
+ }
+}
diff --git a/tests/settings_safety_test.vala b/tests/settings_safety_test.vala
new file mode 100644
index 0000000..d977e28
--- /dev/null
+++ b/tests/settings_safety_test.vala
@@ -0,0 +1,21 @@
+using GLib;
+using Singularity;
+
+int main(string[] args) {
+ Test.init(ref args);
+ Test.add_func("/settings-safety/rejects-unknown-key", () => {
+ Environment.set_variable("GSETTINGS_BACKEND", "memory", true);
+ var settings = new Settings("org.gnome.desktop.interface");
+ Test.expect_message(null, LogLevelFlags.LEVEL_WARNING,
+ "*unknown gsettings key 'definitely-not-a-real-key'*ignored*");
+ assert(!SettingsSafety.set_string(settings, "definitely-not-a-real-key", "value"));
+ Test.assert_expected_messages();
+ });
+ Test.add_func("/settings-safety/writes-known-key", () => {
+ Environment.set_variable("GSETTINGS_BACKEND", "memory", true);
+ var settings = new Settings("org.gnome.desktop.interface");
+ assert(SettingsSafety.set_string(settings, "color-scheme", "prefer-dark"));
+ assert(settings.get_string("color-scheme") == "prefer-dark");
+ });
+ return Test.run();
+}
diff --git a/tests/wallpaper_collections_test.vala b/tests/wallpaper_collections_test.vala
index 8c65a69..de42810 100644
--- a/tests/wallpaper_collections_test.vala
+++ b/tests/wallpaper_collections_test.vala
@@ -16,6 +16,27 @@ private void write_collection(string dir, string filename, string contents) {
}
}
+private void remove_tree(string path) {
+ try {
+ var file = File.new_for_path(path);
+ if (!file.query_exists()) return;
+ if (file.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS) == FileType.DIRECTORY) {
+ var en = file.enumerate_children("standard::name", FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+ FileInfo info;
+ while ((info = en.next_file()) != null) remove_tree(file.get_child(info.get_name()).get_path());
+ }
+ file.delete();
+ } catch (Error e) { error("cleanup failed: %s", e.message); }
+}
+
+private WallpaperCollectionInfo user_collection(string root, string id = "user-pack") {
+ string dir = Path.build_filename(root, id);
+ DirUtils.create_with_parents(dir, 0700);
+ string registry = Path.build_filename(root, id + ".collection");
+ write_collection(root, id + ".collection", "registry\n");
+ return new WallpaperCollectionInfo(id, "User Pack", "", dir, "static", "ocs", registry, root);
+}
+
private void test_parses_id_name_artist_dir() {
string root = make_tmp_dir();
write_collection(root, "brandon.collection",
@@ -90,6 +111,80 @@ private void test_dedupes_by_id_first_root_wins() {
assert(result[0].name == "System");
}
+private void test_unsplash_is_theme_pack() {
+ var collection = new WallpaperCollectionInfo("unsplash", "Unsplash", "", "/tmp/unsplash", "static");
+ assert(collection.theme_pack);
+}
+
+private void test_protected_requires_origin_and_home_path() {
+ string root = make_tmp_dir();
+ string user_dir = Path.build_filename(root, "user-pack");
+ DirUtils.create_with_parents(user_dir, 0700);
+ string registry = Path.build_filename(root, "user-pack.collection");
+ write_collection(root, "user-pack.collection", "registry\n");
+ var no_origin = new WallpaperCollectionInfo("ncz", "NCZ", "", user_dir, "static", "", registry, root);
+ var system_path = new WallpaperCollectionInfo("bing", "Bing", "", "/var/cache/ncz-wallpapers/bing", "static", "bing", registry, root);
+ var user = new WallpaperCollectionInfo("ocs-x", "OCS", "", user_dir, "static", "ocs", registry, root);
+ assert(!no_origin.deletable);
+ assert(!system_path.deletable);
+ assert(user.deletable);
+ remove_tree(root);
+}
+
+private void test_delete_pack_is_scoped() {
+ string root = make_tmp_dir();
+ var target = user_collection(root, "target");
+ var other = user_collection(root, "other");
+ write_collection(target.dir, "one.jpg", "image");
+ write_collection(other.dir, "keep.jpg", "image");
+ try { WallpaperCollections.delete_pack(target); } catch (Error e) { error("delete failed: %s", e.message); }
+ assert(!FileUtils.test(target.dir, FileTest.EXISTS));
+ assert(!FileUtils.test(target.registry_path, FileTest.EXISTS));
+ assert(FileUtils.test(other.dir, FileTest.IS_DIR));
+ assert(FileUtils.test(other.registry_path, FileTest.IS_REGULAR));
+ remove_tree(root);
+}
+
+private void test_delete_image_updates_sidecar_and_manifest() {
+ string root = make_tmp_dir();
+ var collection = user_collection(root);
+ write_collection(collection.dir, "one.jpg", "image");
+ write_collection(collection.dir, "one.json", "{}");
+ write_collection(collection.dir, "two.jpg", "image");
+ write_collection(collection.dir, "pack.json", "{\"images\":[{\"file\":\"one.jpg\"},{\"file\":\"two.jpg\"}]}");
+ try {
+ bool removed_pack = WallpaperCollections.delete_image(collection,
+ File.new_for_path(Path.build_filename(collection.dir, "one.jpg")).get_uri());
+ assert(!removed_pack);
+ } catch (Error e) { error("delete image failed: %s", e.message); }
+ assert(!FileUtils.test(Path.build_filename(collection.dir, "one.jpg"), FileTest.EXISTS));
+ assert(!FileUtils.test(Path.build_filename(collection.dir, "one.json"), FileTest.EXISTS));
+ assert(FileUtils.test(Path.build_filename(collection.dir, "two.jpg"), FileTest.IS_REGULAR));
+ string manifest;
+ try { FileUtils.get_contents(Path.build_filename(collection.dir, "pack.json"), out manifest); }
+ catch (Error e) { error("manifest read failed: %s", e.message); }
+ assert(!manifest.contains("one.jpg"));
+ assert(manifest.contains("two.jpg"));
+ remove_tree(root);
+}
+
+private void test_delete_last_image_removes_pack_and_active_match() {
+ string root = make_tmp_dir();
+ var collection = user_collection(root);
+ string path = Path.build_filename(collection.dir, "only.jpg");
+ write_collection(collection.dir, "only.jpg", "image");
+ string uri = File.new_for_path(path).get_uri();
+ assert(collection.contains_uri(uri));
+ assert(WallpaperCollections.needs_background_fallback(collection, uri));
+ assert(!WallpaperCollections.needs_background_fallback(collection,
+ File.new_for_path(Path.build_filename(root, "other.jpg")).get_uri()));
+ try { assert(WallpaperCollections.delete_image(collection, uri)); }
+ catch (Error e) { error("delete last image failed: %s", e.message); }
+ assert(!FileUtils.test(collection.dir, FileTest.EXISTS));
+ assert(!FileUtils.test(collection.registry_path, FileTest.EXISTS));
+ remove_tree(root);
+}
+
public int main(string[] args) {
Test.init(ref args);
Test.add_func("/wallpaper-collections/parses-id-name-artist-dir", test_parses_id_name_artist_dir);
@@ -97,5 +192,10 @@ public int main(string[] args) {
Test.add_func("/wallpaper-collections/skips-dir-less-collection", test_skips_dir_less_collection);
Test.add_func("/wallpaper-collections/ignores-non-collection-files-and-missing-dirs", test_ignores_non_collection_files_and_missing_dirs);
Test.add_func("/wallpaper-collections/dedupes-by-id-first-root-wins", test_dedupes_by_id_first_root_wins);
+ Test.add_func("/wallpaper-collections/unsplash-theme-pack", test_unsplash_is_theme_pack);
+ Test.add_func("/wallpaper-collections/protected-requires-origin-and-home-path", test_protected_requires_origin_and_home_path);
+ Test.add_func("/wallpaper-collections/delete-pack-is-scoped", test_delete_pack_is_scoped);
+ Test.add_func("/wallpaper-collections/delete-image-updates-sidecar-and-manifest", test_delete_image_updates_sidecar_and_manifest);
+ Test.add_func("/wallpaper-collections/delete-last-image-removes-pack-and-active-match", test_delete_last_image_removes_pack_and_active_match);
return Test.run();
}
diff --git a/tests/wallpaper_ocs_test.vala b/tests/wallpaper_ocs_test.vala
new file mode 100644
index 0000000..15a7d6f
--- /dev/null
+++ b/tests/wallpaper_ocs_test.vala
@@ -0,0 +1,677 @@
+using GLib;
+using Singularity;
+
+private const string PROVIDERS = "{\"schema\":1,\"providers\":{\"pling\":{\"base\":\"https://api.pling.com/ocs/v1/\"},\"opendesktop\":{\"base\":\"https://api.opendesktop.org/ocs/v1/\"},\"kde-look\":{\"base\":\"https://api.kde-look.org/ocs/v1/\"}}}";
+private const string INDEX = "{\"schema\":1,\"entries\":[{\"ref\":\"pling:300\",\"name\":\"Wallpapers\",\"display_name\":\"Desktop\",\"usable\":true},{\"ref\":\"pling:1\",\"name\":\"Phone\",\"usable\":false},{\"ref\":\"kde-look:2\",\"name\":\"Other\",\"usable\":true},{\"ref\":\"pling:300\",\"name\":\"Duplicate\",\"usable\":true}]}";
+private string browse(string items, string provider = "pling", string category = "300") {
+ return "{\"schema\":1,\"provider\":\"%s\",\"category\":\"%s\",\"items\":%s}".printf(provider, category, items);
+}
+private const string ITEM = "{\"provider\":\"pling\",\"id\":\"123\",\"name\":\"Space & \",\"author\":null,\"preview\":null}";
+private const string ITEM_TAGGED = "{\"provider\":\"pling\",\"id\":\"456\",\"name\":\"Tagged\",\"tags\":[\"nature\",\" abstract \",\"nature\",\"\"]}";
+private const string ITEM_NO_TAGS = "{\"provider\":\"pling\",\"id\":\"789\",\"name\":\"Plain\"}";
+private const string ITEM_EMPTY_TAGS = "{\"provider\":\"pling\",\"id\":\"321\",\"name\":\"Empty\",\"tags\":[]}";
+private void test_providers() {
+ try { var rows = WallpaperOcs.providers(PROVIDERS); assert(rows.size == 2); assert(rows[0].id == "kde-look"); assert(rows[1].id == "pling"); } catch (Error e) { error("%s", e.message); }
+}
+private void test_categories() {
+ try { var rows = WallpaperOcs.categories(INDEX, "pling"); assert(rows.size == 1); assert(rows[0].id == "300"); assert(rows[0].name == "Desktop"); } catch (Error e) { error("%s", e.message); }
+}
+private void test_items() {
+ try { var rows = WallpaperOcs.items(browse("[" + ITEM + "," + ITEM + "]"), "pling", "300"); assert(rows.size == 1); assert(rows[0].key == "pling:123"); assert(rows[0].author == ""); assert(rows[0].license == ""); assert(rows[0].preview == ""); assert(rows[0].name == "Space & "); assert(rows[0].tags.length == 0); } catch (Error e) { error("%s", e.message); }
+}
+private void test_tags() {
+ // Present, deduplicated, whitespace-stripped, blanks dropped, order preserved.
+ try {
+ var rows = WallpaperOcs.items(browse("[" + ITEM_TAGGED + "]"), "pling", "300");
+ assert(rows.size == 1);
+ assert(rows[0].tags.length == 2);
+ assert(rows[0].tags[0] == "nature");
+ assert(rows[0].tags[1] == "abstract");
+ } catch (Error e) { error("tagged: %s", e.message); }
+ // Explicit empty array collapses to empty.
+ try {
+ var rows = WallpaperOcs.items(browse("[" + ITEM_EMPTY_TAGS + "]"), "pling", "300");
+ assert(rows.size == 1);
+ assert(rows[0].tags.length == 0);
+ } catch (Error e) { error("empty-tags: %s", e.message); }
+ // Absent field collapses to empty (matches author/license/preview leniency).
+ try {
+ var rows = WallpaperOcs.items(browse("[" + ITEM_NO_TAGS + "]"), "pling", "300");
+ assert(rows.size == 1);
+ assert(rows[0].tags.length == 0);
+ } catch (Error e) { error("no-tags: %s", e.message); }
+ // Format/control characters are removed before deduplication, and tags
+ // made empty by sanitization disappear entirely.
+ try {
+ string unsafe_item = "{\"provider\":\"pling\",\"id\":\"654\",\"name\":\"Unsafe tags\",\"tags\":[\"na\\u202eture\",\"nature\",\"\\u200b\",\"\\u0001 abstract \\u0007\"]}";
+ var rows = WallpaperOcs.items(browse("[" + unsafe_item + "]"), "pling", "300");
+ assert(rows.size == 1);
+ assert(rows[0].tags.length == 2);
+ assert(rows[0].tags[0] == "nature");
+ assert(rows[0].tags[1] == "abstract");
+ } catch (Error e) { error("sanitized-tags: %s", e.message); }
+ // The cap counts Unicode characters rather than UTF-8 bytes.
+ try {
+ string long_tag = string.nfill(65, 'x');
+ string unicode_tag = string.nfill(64, 'x') + "é";
+ string item = "{\"provider\":\"pling\",\"id\":\"987\",\"name\":\"Long tags\",\"tags\":[\"%s\",\"%s\"]}".printf(long_tag, unicode_tag);
+ var rows = WallpaperOcs.items(browse("[" + item + "]"), "pling", "300");
+ assert(rows.size == 1);
+ assert(rows[0].tags.length == 1);
+ assert(rows[0].tags[0] == string.nfill(64, 'x'));
+ } catch (Error e) { error("long-tags: %s", e.message); }
+}
+private void test_empty() {
+ try { assert(WallpaperOcs.items(browse("[]"), "pling", "300").size == 0); } catch (Error e) { error("%s", e.message); }
+}
+private void test_invalid() {
+ string[] bad = { "null", "[]", "{}", "not json", "{\"schema\":2,\"providers\":{}}", "{\"schema\":\"1\",\"providers\":{}}", "{\"schema\":1,\"providers\":[]}", "{\"schema\":1,\"providers\":{\"--bad\":{}}}" };
+ foreach (string data in bad) { bool rejected = false; try { WallpaperOcs.providers(data); } catch (Error e) { rejected = true; } assert(rejected); }
+}
+private void test_bad_items() {
+ string[] bad = { browse("[]", "kde-look"), browse("[]", "pling", "2"), browse("null"), browse("[null]"), browse("[{\"provider\":\"pling\",\"id\":123,\"name\":\"x\"}]"), browse("[" + ITEM.replace("pling", "kde-look") + "]"), browse("[" + ITEM.replace("null", "42") + "]"), browse("[" + ITEM.replace("null", "{\"tags\":42}") + "]"), browse("[" + ITEM.replace("null", "{\"tags\":[\"ok\",42]}") + "]") };
+ foreach (string data in bad) { bool rejected = false; try { WallpaperOcs.items(data, "pling", "300"); } catch (Error e) { rejected = true; } assert(rejected); }
+}
+private void test_bad_categories() {
+ foreach (string data in new string[] { INDEX.replace("true", "\"true\""), INDEX.replace("pling:300", "pling:--bad") }) {
+ bool rejected = false; try { WallpaperOcs.categories(data, "pling"); } catch (Error e) { rejected = true; } assert(rejected);
+ }
+}
+private void test_import_retry() {
+ var state = new WallpaperOcsImports(); assert(state.begin("pling:123")); assert(state.busy); assert(!state.begin("pling:123")); assert(!state.begin("pling:456")); state.fail("pling:456"); assert(state.busy); state.fail("pling:123"); assert(!state.busy); assert(!state.is_added("pling:123")); assert(state.begin("pling:123"));
+}
+private void remove_tree(string path) {
+ try { var dir = Dir.open(path); string? name; while ((name = dir.read_name()) != null) { string child = Path.build_filename(path, name); if (FileUtils.test(child, FileTest.IS_DIR)) remove_tree(child); else FileUtils.unlink(child); } DirUtils.remove(path); } catch (Error e) { error("cleanup: %s", e.message); }
+}
+
+// Build the per-image sidecar JSON shape that the ncz-wallpaper-ocs helper
+// now writes: schema=1, origin="ocs", pack_id="imported-ocs", with provider
+// + source.ocs_id encoded in the same fields discover()/complete() parse.
+private string make_sidecar(string provider, string ocs_id, string image_filename) {
+ return "{\"schema\":1,\"origin\":\"ocs\",\"pack_id\":\"imported-ocs\",\"image\":{\"file\":\"" + image_filename + "\"}," +
+ "\"provider\":\"" + provider + "\"," +
+ "\"source\":{\"ocs_id\":\"" + ocs_id + "\",\"detailpage\":\"https://example/" + ocs_id + "\"," +
+ "\"download_url\":\"https://example/file\",\"downloadname1\":\"" + image_filename + "\",\"downloadsize1_kib\":42,\"tags\":[]}}";
+}
+
+// Build the new import-command return payload: pack_id fixed at "imported-ocs",
+// destination = shared directory, collection = shared .collection file, images
+// each with {file, sidecar}. No "pack_json" field any more (deleted).
+private string make_payload(string destination, string collection_path, string image_filename, string sidecar_path) {
+ return "{\"pack_id\":\"imported-ocs\",\"destination\":\"" + destination +
+ "\",\"collection\":\"" + collection_path +
+ "\",\"images\":[{\"file\":\"" + image_filename + "\",\"title\":\"Test\",\"sidecar\":\"" + sidecar_path + "\"}]}";
+}
+
+private void test_import_complete() {
+ // New model: one shared "imported-ocs" directory + .collection file, with
+ // a per-image sidecar next to each normalized image. The pack_id returned
+ // by the helper is the fixed string "imported-ocs", not a per-import id.
+ string root = "";
+ try {
+ root = DirUtils.make_tmp("ocs-test-XXXXXX");
+ string pack_dir = Path.build_filename(root, "imported-ocs");
+ DirUtils.create(pack_dir, 0700);
+ string collection_path = Path.build_filename(root, "imported-ocs.collection");
+ FileUtils.set_contents(collection_path,
+ "[Collection]\nId=imported-ocs\nName=Imported from OCS\nType=static\nDir=" + pack_dir + "\n");
+ string image_filename = "pling-123-01-foo.jpg";
+ string sidecar_filename = "pling-123-01-foo.json";
+ FileUtils.set_contents(Path.build_filename(pack_dir, image_filename), "fixture-jpg");
+ FileUtils.set_contents(Path.build_filename(pack_dir, sidecar_filename),
+ make_sidecar("pling", "123", image_filename));
+
+ string result = make_payload(pack_dir, collection_path, image_filename,
+ sidecar_filename);
+
+ var state = new WallpaperOcsImports();
+ assert(state.begin("pling:123"));
+ // Missing .jpg -> complete() must reject and NOT mark added.
+ bool rejected = false;
+ try { state.complete("pling:123", result.replace("pling-123-01-foo.jpg", "missing.jpg"), {root}); }
+ catch (Error e) { rejected = true; }
+ assert(rejected);
+ assert(!state.is_added("pling:123"));
+ assert(state.busy);
+ // Happy path.
+ state.complete("pling:123", result, {root});
+ assert(!state.busy);
+ assert(state.is_added("pling:123"));
+ assert(!state.begin("pling:123"));
+
+ // New helpers use one theme pack per provider, with the same
+ // per-image sidecar contract as the former shared collection.
+ FileUtils.set_contents(collection_path,
+ "[Collection]\nId=pling\nName=Pling\nType=static\nDir=" + pack_dir + "\n");
+ var theme = new WallpaperOcsImports();
+ assert(theme.begin("pling:123"));
+ theme.complete("pling:123", result.replace("\"pack_id\":\"imported-ocs\"", "\"pack_id\":\"pling\""), {root});
+ assert(theme.is_added("pling:123"));
+ assert(!theme.busy);
+
+ // A fresh imports model loaded from disk sees the import via discover().
+ var reopened = new WallpaperOcsImports();
+ reopened.discover(WallpaperCollections.parse({root}));
+ assert(reopened.is_added("pling:123"));
+ assert(!reopened.is_added("kde-look:123"));
+
+ // If the sidecar disappears, the key is no longer found. discover()
+ // must not crash, just not include it.
+ FileUtils.unlink(Path.build_filename(pack_dir, sidecar_filename));
+ var missing = new WallpaperOcsImports();
+ missing.discover(WallpaperCollections.parse({root}));
+ assert(!missing.is_added("pling:123"));
+ } catch (Error e) { error("%s", e.message); }
+ remove_tree(root);
+}
+
+private void test_discover_collects_multiple_sidecars_in_one_dir() {
+ // The shared imported-ocs directory can hold many images from many
+ // providers; discover() must add a key for each sidecar, not just the
+ // first. This is the key behaviour that makes the old one-pack-per-import
+ // scan obsolete: discover() now means "scan sidecars, not a single
+ // pack.json".
+ string root = "";
+ try {
+ root = DirUtils.make_tmp("ocs-test-XXXXXX");
+ string pack_dir = Path.build_filename(root, "imported-ocs");
+ DirUtils.create(pack_dir, 0700);
+ FileUtils.set_contents(Path.build_filename(root, "imported-ocs.collection"),
+ "[Collection]\nId=imported-ocs\nName=Imported from OCS\nType=static\nDir=" + pack_dir + "\n");
+ // Two images from different providers, each with its sidecar.
+ FileUtils.set_contents(Path.build_filename(pack_dir, "pling-111-01-a.jpg"), "x");
+ FileUtils.set_contents(Path.build_filename(pack_dir, "pling-111-01-a.json"),
+ make_sidecar("pling", "111", "pling-111-01-a.jpg"));
+ FileUtils.set_contents(Path.build_filename(pack_dir, "kde-look-222-01-b.jpg"), "x");
+ FileUtils.set_contents(Path.build_filename(pack_dir, "kde-look-222-01-b.json"),
+ make_sidecar("kde-look", "222", "kde-look-222-01-b.jpg"));
+
+ var state = new WallpaperOcsImports();
+ state.discover(WallpaperCollections.parse({root}));
+ assert(state.is_added("pling:111"));
+ assert(state.is_added("kde-look:222"));
+ assert(!state.is_added("pling:999"));
+ } catch (Error e) { error("%s", e.message); }
+ remove_tree(root);
+}
+
+private void test_discover_skips_orphan_sidecar_without_image() {
+ // A sidecar JSON without its paired .jpg must NOT count as an import.
+ // discover() must not crash, just skip it -- this is the safety property
+ // that kept the old code honest (only normalized image files count, not
+ // metadata alone) applied to the new per-image model.
+ string root = "";
+ try {
+ root = DirUtils.make_tmp("ocs-test-XXXXXX");
+ string pack_dir = Path.build_filename(root, "imported-ocs");
+ DirUtils.create(pack_dir, 0700);
+ FileUtils.set_contents(Path.build_filename(root, "imported-ocs.collection"),
+ "[Collection]\nId=imported-ocs\nDir=" + pack_dir + "\n");
+ FileUtils.set_contents(Path.build_filename(pack_dir, "pling-333-01-c.json"),
+ make_sidecar("pling", "333", "pling-333-01-c.jpg"));
+
+ var state = new WallpaperOcsImports();
+ state.discover(WallpaperCollections.parse({root}));
+ assert(!state.is_added("pling:333"));
+ } catch (Error e) { error("%s", e.message); }
+ remove_tree(root);
+}
+
+private void test_discover_tolerates_old_shape_directory() {
+ // Old-shape directory left over from one-pack-per-import testing has a
+ // pack.json but no sidecars. discover() must skip it silently rather
+ // than error. This is the explicit "do not crash on the old convention"
+ // requirement.
+ string root = "";
+ try {
+ root = DirUtils.make_tmp("ocs-test-XXXXXX");
+ string old_pack = Path.build_filename(root, "ocs-pling-555-oldstyle");
+ DirUtils.create(old_pack, 0700);
+ FileUtils.set_contents(Path.build_filename(root, "ocs-pling-555-oldstyle.collection"),
+ "[Collection]\nId=ocs-pling-555-oldstyle\nName=Old\nDir=" + old_pack + "\n");
+ FileUtils.set_contents(Path.build_filename(old_pack, "pack.json"),
+ "{\"origin\":\"ocs\",\"provider\":\"pling\",\"source\":{\"ocs_id\":\"555\"},\"images\":[{\"file\":\"01.jpg\"}]}");
+ FileUtils.set_contents(Path.build_filename(old_pack, "01.jpg"), "oldshape");
+
+ var state = new WallpaperOcsImports();
+ state.discover(WallpaperCollections.parse({root}));
+ assert(state.is_added("pling:555"));
+ } catch (Error e) { error("%s", e.message); }
+ remove_tree(root);
+}
+
+private void test_import_complete_accepts_deployed_legacy_shape() {
+ string root = "";
+ try {
+ root = DirUtils.make_tmp("ocs-test-XXXXXX");
+ string pack_id = "ocs-pling-555-oldstyle";
+ string pack_dir = Path.build_filename(root, pack_id);
+ DirUtils.create(pack_dir, 0700);
+ string collection_path = Path.build_filename(root, pack_id + ".collection");
+ FileUtils.set_contents(collection_path,
+ "[Collection]\nId=" + pack_id + "\nName=Old\nDir=" + pack_dir + "\n");
+ FileUtils.set_contents(Path.build_filename(pack_dir, "pack.json"),
+ "{\"origin\":\"ocs\",\"provider\":\"pling\",\"source\":{\"ocs_id\":\"555\"},\"images\":[{\"file\":\"01.jpg\"}]}");
+ FileUtils.set_contents(Path.build_filename(pack_dir, "01.jpg"), "oldshape");
+ string payload = "{\"pack_id\":\"" + pack_id + "\",\"destination\":\"" + pack_dir +
+ "\",\"collection\":\"" + collection_path +
+ "\",\"images\":[{\"file\":\"01.jpg\",\"title\":\"Test\"}]}";
+
+ var state = new WallpaperOcsImports();
+ assert(state.begin("pling:555"));
+ state.complete("pling:555", payload, {root});
+ assert(!state.busy);
+ assert(state.is_added("pling:555"));
+ } catch (Error e) { error("%s", e.message); }
+ remove_tree(root);
+}
+
+private void test_import_complete_rejects_payload_without_sidecar_path() {
+ // Backwards-safety: a payload that still has the old per-image shape
+ // (no "sidecar" field per image) must be rejected. The Python helper
+ // generates sidecars now, so a missing one is a real failure, not a
+ // legacy shape we silently accept.
+ string root = "";
+ try {
+ root = DirUtils.make_tmp("ocs-test-XXXXXX");
+ string pack_dir = Path.build_filename(root, "imported-ocs");
+ DirUtils.create(pack_dir, 0700);
+ string collection_path = Path.build_filename(root, "imported-ocs.collection");
+ FileUtils.set_contents(collection_path,
+ "[Collection]\nId=imported-ocs\nDir=" + pack_dir + "\n");
+ string image_filename = "pling-777-01-x.jpg";
+ FileUtils.set_contents(Path.build_filename(pack_dir, image_filename), "fixture");
+ // Build a payload that looks almost right except the per-image entry
+ // lacks the "sidecar" field.
+ string bad_payload = "{\"pack_id\":\"imported-ocs\",\"destination\":\"" + pack_dir +
+ "\",\"collection\":\"" + collection_path +
+ "\",\"images\":[{\"file\":\"" + image_filename + "\",\"title\":\"x\"}]}";
+ var state = new WallpaperOcsImports();
+ assert(state.begin("pling:777"));
+ bool rejected = false;
+ try { state.complete("pling:777", bad_payload, {root}); }
+ catch (Error e) { rejected = true; }
+ assert(rejected);
+ assert(!state.is_added("pling:777"));
+ } catch (Error e) { error("%s", e.message); }
+ remove_tree(root);
+}
+
+// Sample TSV emitted by `ncz-wallpaper-bing markets`. One "\t"
+// per line; the real helper hardcodes eight markets. We use a representative
+// four here so the parser is exercised on realistic input.
+private const string BING_MARKETS_TSV =
+ "en-US\tUnited States\n" +
+ "en-GB\tUnited Kingdom\n" +
+ "en-AU\tAustralia\n" +
+ "ja-JP\tJapan\n";
+// Sample list JSON emitted by `ncz-wallpaper-bing list `. The shape
+// is a bare JSON array (no schema/items wrapper). Includes both pinned:true
+// and pinned:false so the bool parser is exercised on both branches.
+private const string BING_LIST_JSON =
+ "[{\"provider\":\"bing\",\"date\":\"20260818\",\"market\":\"en-US\"," +
+ "\"image_id\":\"OHR.Palmanova_EN-US0340289339\"," +
+ "\"path\":\"/var/cache/ncz-wallpapers/bing/en-US/20260818.jpg\"," +
+ "\"caption\":\"Palmanova\",\"copyright\":\"Marco Zoccheddu/Getty Images\"," +
+ "\"thumbnail_path\":\"/home/u/.cache/ncz-wallpapers/thumbs/bing/en-US/20260818_400x240.jpg\"," +
+ "\"pinned\":true}," +
+ "{\"provider\":\"bing\",\"date\":\"20260817\",\"market\":\"en-US\"," +
+ "\"path\":\"/var/cache/ncz-wallpapers/bing/en-US/20260817.jpg\"," +
+ "\"caption\":\"Cliffside path\",\"copyright\":\"Sample Author\"," +
+ "\"thumbnail_path\":\"/home/u/.cache/ncz-wallpapers/thumbs/bing/en-US/20260817_400x240.jpg\"," +
+ "\"pinned\":false}]";
+
+private void test_bing_markets_parses_tsv() {
+ // Realistic TSV -> the four markets above, sorted by display name.
+ try {
+ var rows = WallpaperBing.markets(BING_MARKETS_TSV);
+ assert(rows.size == 4);
+ // Sorted alphabetically by name: Australia, Japan, United Kingdom, United States.
+ assert(rows[0].id == "en-AU" && rows[0].name == "Australia");
+ assert(rows[1].id == "ja-JP" && rows[1].name == "Japan");
+ assert(rows[2].id == "en-GB" && rows[2].name == "United Kingdom");
+ assert(rows[3].id == "en-US" && rows[3].name == "United States");
+ } catch (Error e) { error("markets: %s", e.message); }
+}
+
+private void test_bing_markets_tolerates_blank_lines_and_whitespace() {
+ // Blank lines, trailing whitespace, and a comment-shaped line with no
+ // tab must not crash the parser -- the real helper is well-formed, but
+ // future versions or wrapper scripts may add comments and blank lines.
+ string noisy = "\n \nen-US\tUnited States\n# this looks like a comment\n\nen-GB\tUnited Kingdom \n";
+ try {
+ var rows = WallpaperBing.markets(noisy);
+ assert(rows.size == 2);
+ assert(rows[0].id == "en-GB" && rows[0].name == "United Kingdom");
+ assert(rows[1].id == "en-US" && rows[1].name == "United States");
+ } catch (Error e) { error("markets-noisy: %s", e.message); }
+}
+
+private void test_bing_combined_view_absent_when_helper_omits_it() {
+ // A subset of markets is configured, so the helper advertises markets
+ // only. combined_view() must say so by returning null -- the browser
+ // then crawls every market exactly as before.
+ try {
+ assert(WallpaperBing.combined_view(WallpaperBing.markets(BING_MARKETS_TSV)) == null);
+ } catch (Error e) { error("combined-absent: %s", e.message); }
+}
+
+private void test_bing_combined_view_replaces_the_market_list() {
+ // "all" is configured, so the helper prepends the combined pseudo-market.
+ // It must REPLACE the market list, not join it: the browser crawls one
+ // listing per choice into a single grid, so keeping both would show the
+ // de-duplicated set and every raw per-market set together and restore
+ // the duplication the combined view exists to remove.
+ string tsv = "consolidated\tCombined (All Markets)\n" + BING_MARKETS_TSV;
+ try {
+ var rows = WallpaperBing.markets(tsv);
+ assert(rows.size == 5);
+ var only = WallpaperBing.combined_view(rows);
+ assert(only != null);
+ assert(only.size == 1);
+ assert(only[0].id == WallpaperBing.CONSOLIDATED_ID);
+ assert(only[0].name == "Combined (All Markets)");
+ } catch (Error e) { error("combined-present: %s", e.message); }
+}
+
+private void test_bing_markets_empty() {
+ // An empty response is a valid edge case (helper not installed, etc).
+ try { assert(WallpaperBing.markets("").size == 0); } catch (Error e) { error("markets-empty: %s", e.message); }
+ try { assert(WallpaperBing.markets("\n\n\n").size == 0); } catch (Error e) { error("markets-blank: %s", e.message); }
+}
+
+private void test_bing_items_parses_list_array() {
+ // The two-entry fixture above: one pinned, one not. Both items must
+ // populate the shared WallpaperItem shape, with Bing-only fields
+ // filled in (market, thumbnail_path, pinned). item.key must be unique
+ // and provider-namespaced.
+ try {
+ var rows = WallpaperBing.items(BING_LIST_JSON);
+ assert(rows.size == 2);
+ // Pinned item first.
+ assert(rows[0].provider_id == "bing");
+ assert(rows[0].market == "en-US");
+ assert(rows[0].pinned == true);
+ assert(rows[0].name == "Palmanova");
+ assert(rows[0].author == "Marco Zoccheddu/Getty Images");
+ assert(rows[0].license == "");
+ assert(rows[0].preview == "");
+ assert(rows[0].tags.length == 0);
+ assert(rows[0].id == "en-US:OHR.Palmanova_EN-US0340289339");
+ assert(rows[0].key == "bing:en-US:OHR.Palmanova_EN-US0340289339");
+ assert(rows[0].archive_date == "20260818");
+ assert(rows[0].bing_image_id == "OHR.Palmanova_EN-US0340289339");
+ assert(rows[0].thumbnail_path == "/home/u/.cache/ncz-wallpapers/thumbs/bing/en-US/20260818_400x240.jpg");
+ // Unpinned item: pinned=false, different date.
+ assert(rows[1].pinned == false);
+ assert(rows[1].id == "en-US:20260817");
+ assert(rows[1].key == "bing:en-US:20260817");
+ assert(rows[1].name == "Cliffside path");
+ } catch (Error e) { error("items: %s", e.message); }
+}
+
+private void test_bing_items_empty_array() {
+ // An empty archive for a market is a valid edge case (no images yet).
+ try { assert(WallpaperBing.items("[]").size == 0); } catch (Error e) { error("items-empty: %s", e.message); }
+}
+
+private void test_bing_items_rejects_non_array_root() {
+ // Anything that isn't a JSON array is a parse error, mirroring the
+ // OCS-side "Expected a JSON object" guard. The Bing helper returns a
+ // bare array, never an object, so any non-array means we are
+ // talking to the wrong command / a broken helper.
+ string[] bad = {
+ "null",
+ "{}",
+ "{\"images\":[]}",
+ "not json",
+ "[{\"provider\":\"pling\",\"date\":\"20260818\",\"market\":\"en-US\"}]",
+ };
+ foreach (string data in bad) {
+ bool rejected = false;
+ try { WallpaperBing.items(data); }
+ catch (Error e) { rejected = true; }
+ assert(rejected);
+ }
+}
+
+private void test_bing_items_rejects_bad_pinned_field() {
+ // pinned must be a JSON bool; an int/string/null is a parse error so
+ // a malformed helper cannot silently drop the toggle state.
+ string bad = "[{\"provider\":\"bing\",\"date\":\"20260818\",\"market\":\"en-US\"," +
+ "\"path\":\"/var/cache/ncz-wallpapers/bing/en-US/20260818.jpg\"," +
+ "\"caption\":\"x\",\"copyright\":\"y\",\"pinned\":\"yes\"}]";
+ bool rejected = false;
+ try { WallpaperBing.items(bad); } catch (Error e) { rejected = true; }
+ assert(rejected);
+ // Missing pinned is also a parse error -- the field is load-bearing.
+ string missing = "[{\"provider\":\"bing\",\"date\":\"20260818\",\"market\":\"en-US\"," +
+ "\"path\":\"/var/cache/ncz-wallpapers/bing/en-US/20260818.jpg\"," +
+ "\"caption\":\"x\",\"copyright\":\"y\"}]";
+ rejected = false;
+ try { WallpaperBing.items(missing); } catch (Error e) { rejected = true; }
+ assert(rejected);
+}
+
+private Gee.ArrayList cache_fixture() {
+ var entries = new Gee.ArrayList();
+ var ocs = new WallpaperItem();
+ ocs.provider_id = "pling";
+ ocs.id = "123";
+ ocs.name = "Space & ";
+ ocs.author = "Ada";
+ ocs.license = "CC-BY-4.0";
+ ocs.preview = "https://example.test/preview.jpg";
+ ocs.full_res_url = "https://example.test/full.jpg";
+ ocs.page_url = "https://example.test/page";
+ ocs.tags = { "nature", "4K" };
+ ocs.width = 3840;
+ ocs.height = 2160;
+ entries.add(new WallpaperBrowseCacheEntry(ocs, "pling:300"));
+ var bing = new WallpaperItem();
+ bing.provider_id = "bing";
+ bing.id = "en-US:OHR.Example";
+ bing.name = "A lake";
+ bing.market = "en-US";
+ bing.archive_date = "20260818";
+ bing.bing_image_id = "OHR.Example";
+ bing.thumbnail_path = "/home/u/.cache/ncz-wallpapers/thumbs/bing/en-US/20260818_400x240.jpg";
+ bing.pinned = true;
+ entries.add(new WallpaperBrowseCacheEntry(bing, "en-US"));
+ return entries;
+}
+
+private void test_cache_roundtrip() {
+ string data = WallpaperBrowseCache.serialize("ocs", cache_fixture(), false, 1700000000);
+ try {
+ var cache = WallpaperBrowseCache.parse(data, "ocs");
+ assert(cache.provider == "ocs");
+ assert(cache.created == 1700000000);
+ assert(!cache.partial);
+ assert(cache.entries.size == 2);
+ var first = cache.entries[0];
+ assert(first.category == "pling:300");
+ assert(first.item.key == "pling:123");
+ assert(first.item.name == "Space & ");
+ assert(first.item.author == "Ada");
+ assert(first.item.license == "CC-BY-4.0");
+ assert(first.item.preview == "https://example.test/preview.jpg");
+ assert(first.item.full_res_url == "https://example.test/full.jpg");
+ assert(first.item.page_url == "https://example.test/page");
+ assert(first.item.width == 3840 && first.item.height == 2160);
+ assert(first.item.tags.length == 2 && first.item.tags[0] == "nature" && first.item.tags[1] == "4K");
+ // Bing-only fields survive the round trip; without them a cached Bing
+ // grid would lose its local thumbnails and its pin state.
+ var second = cache.entries[1];
+ assert(second.category == "en-US");
+ assert(second.item.key == "bing:en-US:OHR.Example");
+ assert(second.item.market == "en-US");
+ assert(second.item.archive_date == "20260818");
+ assert(second.item.bing_image_id == "OHR.Example");
+ assert(second.item.thumbnail_path.has_suffix("20260818_400x240.jpg"));
+ assert(second.item.pinned);
+ } catch (Error e) { error("roundtrip: %s", e.message); }
+}
+
+private void test_cache_rejects_corrupt() {
+ string good = WallpaperBrowseCache.serialize("ocs", cache_fixture(), false, 1700000000);
+ string[] bad = {
+ "", "not json", "[]", "{}", "null",
+ good.replace("\"schema\":1", "\"schema\":2"),
+ good.replace("\"created\":1700000000", "\"created\":\"soon\""),
+ good.replace("\"created\":1700000000", "\"created\":-5"),
+ good.replace("\"partial\":false", "\"partial\":\"no\""),
+ good.replace("\"items\":[", "\"items\":{\"a\":["),
+ good.replace("\"pinned\":true", "\"pinned\":1"),
+ good.replace("\"id\":\"123\"", "\"id\":\"\""),
+ good.replace("\"tags\":[\"nature\",\"4K\"]", "\"tags\":42"),
+ // Truncation is the realistic corruption: a write interrupted by a
+ // full disk or a crash leaves a prefix of valid JSON.
+ good.substring(0, good.length / 2)
+ };
+ foreach (string data in bad) {
+ bool rejected = false;
+ try { WallpaperBrowseCache.parse(data, "ocs"); } catch (Error e) { rejected = true; }
+ assert(rejected);
+ }
+ // A cache written for another provider must not be served to this one.
+ bool wrong_provider = false;
+ try { WallpaperBrowseCache.parse(good, "bing"); } catch (Error e) { wrong_provider = true; }
+ assert(wrong_provider);
+}
+
+private void test_cache_ttl() {
+ var entries = cache_fixture();
+ try {
+ var fresh = WallpaperBrowseCache.parse(
+ WallpaperBrowseCache.serialize("ocs", entries, false, 1700000000), "ocs");
+ assert(fresh.fresh(1700000000));
+ assert(fresh.fresh(1700000000 + WallpaperBrowseCache.TTL_SECONDS - 1));
+ assert(!fresh.fresh(1700000000 + WallpaperBrowseCache.TTL_SECONDS));
+ // A cache stamped in the future is a clock change, not a fresh crawl.
+ assert(!fresh.fresh(1700000000 - 1));
+ assert(fresh.age(1700000000 + 300) == 300);
+ // A crawl that lost categories expires far sooner, so a transient
+ // network failure cannot pin a degraded grid for the full TTL.
+ var partial = WallpaperBrowseCache.parse(
+ WallpaperBrowseCache.serialize("ocs", entries, true, 1700000000), "ocs");
+ assert(partial.partial);
+ assert(partial.fresh(1700000000 + WallpaperBrowseCache.PARTIAL_TTL_SECONDS - 1));
+ assert(!partial.fresh(1700000000 + WallpaperBrowseCache.PARTIAL_TTL_SECONDS));
+ } catch (Error e) { error("ttl: %s", e.message); }
+}
+
+private void test_cache_read_write() {
+ string root = "";
+ try {
+ root = DirUtils.make_tmp("wallpaper-cache-XXXXXX");
+ // Nested path: write() owns creating the directory, as it must on a
+ // machine that has never opened this page.
+ string path = Path.build_filename(root, "wallpaper-browse", "ocs.json");
+ assert(WallpaperBrowseCache.read(path, "ocs", 1700000000) == null);
+ assert(WallpaperBrowseCache.write(path, "ocs", cache_fixture(), false, 1700000000));
+ var loaded = WallpaperBrowseCache.read(path, "ocs", 1700000000 + 60);
+ assert(loaded != null);
+ assert(loaded.entries.size == 2);
+ // Past its TTL the same file reads as absent rather than as an error.
+ assert(WallpaperBrowseCache.read(path, "ocs", 1700000000 + WallpaperBrowseCache.TTL_SECONDS) == null);
+ // A corrupt file degrades to "no cache", never to a throw.
+ FileUtils.set_contents(path, "{\"schema\":1,\"provider\":\"ocs\",");
+ assert(WallpaperBrowseCache.read(path, "ocs", 1700000000) == null);
+ // A provider id that is not filename-safe is refused outright.
+ assert(!WallpaperBrowseCache.write(Path.build_filename(root, "x.json"), "../escape",
+ cache_fixture(), false, 1700000000));
+ assert(WallpaperBrowseCache.path_for("ocs").has_suffix("singularity/wallpaper-browse/ocs.json"));
+ } catch (Error e) { error("read-write: %s", e.message); }
+ remove_tree(root);
+}
+
+public int main(string[] args) {
+ Test.init(ref args);
+ Test.add_func("/providers/release-registry", () => {
+ var registry = new WallpaperProviderRegistry();
+ var active = registry.get_active();
+ var available = registry.get_available();
+ assert(active.size == 2);
+ assert(active[0].id == "ocs");
+ assert(active[1].id == "bing");
+ assert(!active[0].requires_credentials && !active[1].requires_credentials);
+ assert(available.size == 4);
+ assert(available[2].id == "openverse" && available[2].supports_search);
+ assert(available[3].id == "unsplash" && available[3].supports_search);
+ assert(registry.lookup("openverse") == null);
+ assert(registry.lookup("unsplash") == null);
+ });
+ Test.add_func("/ocs/unified-categories", () => {
+ try {
+ var aggregate = WallpaperOcs.categories(INDEX, "ocs");
+ assert(aggregate.size == 2);
+ assert(aggregate[0].id.contains(":"));
+ assert(aggregate[1].id.contains(":"));
+ }
+ catch (Error e) { error("%s", e.message); }
+ });
+ Test.add_func("/ocs/unified-items", () => {
+ try {
+ var rows = WallpaperOcs.items(browse("[" + ITEM + "," + ITEM.replace("pling", "kde-look").replace("123", "456") + "]", "ocs"), "ocs", "300");
+ assert(rows.size == 2);
+ assert(rows[1].provider_id == "kde-look");
+ } catch (Error e) { error("%s", e.message); }
+ });
+ Test.add_func("/openverse/normalized-items", () => {
+ string item = "{\"provider\":\"openverse\",\"id\":\"c6a260ef-8a37-43df-939e-f3d662403fcc\",\"name\":null,\"author\":null,\"license\":\"by-sa\",\"license_version\":\"2.5\",\"preview\":\"https://api.openverse.org/thumb\",\"url\":\"https://example.test/full.jpg\",\"width\":4096,\"height\":2160,\"attribution\":\"A & \",\"tags\":[\"4K\"]}";
+ try {
+ var rows = WallpaperOpenverse.items("{\"schema\":1,\"items\":[" + item + "," + item + "]}");
+ assert(rows.size == 1);
+ assert(rows[0].license == "by-sa 2.5");
+ assert(rows[0].attribution == "A & ");
+ assert(rows[0].name == "");
+ assert(rows[0].full_res_url == "https://example.test/full.jpg");
+ assert(rows[0].width == 4096 && rows[0].height == 2160);
+ } catch (Error e) { error("%s", e.message); }
+ foreach (string bad in new string[] {"{}", "[]", "{\"schema\":1,\"items\":[" + item.replace("openverse", "pling") + "]}", "{\"schema\":1,\"items\":[" + item.replace("c6a260ef-8a37-43df-939e-f3d662403fcc", "../escape") + "]}"}) {
+ bool rejected = false;
+ try { WallpaperOpenverse.items(bad); } catch (Error e) { rejected = true; }
+ assert(rejected);
+ }
+ });
+ Test.add_func("/stock/unsplash-normalized-items", () => {
+ string item = "{\"provider\":\"unsplash\",\"id\":\"abc_123-X\",\"name\":\"Mountain\",\"author\":\"Ada\",\"license\":\"Unsplash License\",\"license_version\":\"\",\"preview\":\"https://images.unsplash.com/a\",\"attribution\":\"Photo by Ada on Unsplash\",\"page_url\":\"https://unsplash.com/photos/a\",\"license_url\":\"https://unsplash.com/license\",\"tags\":[\"4K\"]}";
+ try {
+ var rows = WallpaperOpenverse.items("{\"schema\":1,\"items\":[" + item + "]}");
+ assert(rows.size == 1);
+ assert(rows[0].key == "unsplash:abc_123-X");
+ assert(rows[0].attribution == "Photo by Ada on Unsplash");
+ } catch (Error e) { assert_not_reached(); }
+ try {
+ WallpaperOpenverse.items("{\"schema\":1,\"items\":[" + item.replace("abc_123-X", "../escape") + "]}");
+ assert_not_reached();
+ } catch (Error e) { }
+ });
+ Test.add_func("/openverse/import-and-discover", () => {
+ string root = "";
+ try {
+ root = DirUtils.make_tmp("openverse-test-XXXXXX");
+ string registry = Path.build_filename(root, "openverse.collection");
+ FileUtils.set_contents(registry, "[Collection]\nId=openverse\nName=Openverse\nType=static\nDir=" + root + "\n");
+ string identity = "c6a260ef-8a37-43df-939e-f3d662403fcc";
+ FileUtils.set_contents(Path.build_filename(root, "photo.png"), "fixture");
+ FileUtils.set_contents(Path.build_filename(root, "photo.json"), "{\"provider\":\"openverse\",\"id\":\"" + identity + "\"}");
+ var state = new WallpaperOcsImports();
+ assert(state.begin("openverse:" + identity));
+ state.complete("openverse:" + identity, make_payload(root, registry, "photo.png", "photo.json").replace("imported-ocs", "openverse"), {root});
+ var fresh = new WallpaperOcsImports();
+ var collections = WallpaperCollections.parse({root});
+ assert(collections[0].theme_pack);
+ fresh.discover(collections);
+ assert(fresh.is_added("openverse:" + identity));
+ } catch (Error e) { error("%s", e.message); }
+ remove_tree(root);
+ });
+ Test.add_func("/ocs/providers", test_providers); Test.add_func("/ocs/categories", test_categories); Test.add_func("/ocs/items", test_items); Test.add_func("/ocs/tags", test_tags); Test.add_func("/ocs/empty", test_empty); Test.add_func("/ocs/invalid", test_invalid); Test.add_func("/ocs/bad-items", test_bad_items); Test.add_func("/ocs/bad-categories", test_bad_categories); Test.add_func("/ocs/import-retry", test_import_retry); Test.add_func("/ocs/import-complete", test_import_complete); Test.add_func("/ocs/discover-collects-multiple-sidecars-in-one-dir", test_discover_collects_multiple_sidecars_in_one_dir); Test.add_func("/ocs/discover-skips-orphan-sidecar-without-image", test_discover_skips_orphan_sidecar_without_image); Test.add_func("/ocs/discover-tolerates-old-shape-directory", test_discover_tolerates_old_shape_directory); Test.add_func("/ocs/import-complete-accepts-deployed-legacy-shape", test_import_complete_accepts_deployed_legacy_shape); Test.add_func("/ocs/import-complete-rejects-payload-without-sidecar-path", test_import_complete_rejects_payload_without_sidecar_path); Test.add_func("/ocs/bing-markets-parses-tsv", test_bing_markets_parses_tsv); Test.add_func("/ocs/bing-markets-tolerates-blank-lines-and-whitespace", test_bing_markets_tolerates_blank_lines_and_whitespace); Test.add_func("/ocs/bing-combined-view-absent-when-helper-omits-it", test_bing_combined_view_absent_when_helper_omits_it); Test.add_func("/ocs/bing-combined-view-replaces-the-market-list", test_bing_combined_view_replaces_the_market_list); Test.add_func("/ocs/bing-markets-empty", test_bing_markets_empty); Test.add_func("/ocs/bing-items-parses-list-array", test_bing_items_parses_list_array); Test.add_func("/ocs/bing-items-empty-array", test_bing_items_empty_array); Test.add_func("/ocs/bing-items-rejects-non-array-root", test_bing_items_rejects_non_array_root); Test.add_func("/ocs/bing-items-rejects-bad-pinned-field", test_bing_items_rejects_bad_pinned_field);
+ Test.add_func("/browse-cache/roundtrip", test_cache_roundtrip);
+ Test.add_func("/browse-cache/rejects-corrupt", test_cache_rejects_corrupt);
+ Test.add_func("/browse-cache/ttl", test_cache_ttl);
+ Test.add_func("/browse-cache/read-write", test_cache_read_write);
+ return Test.run();
+}
diff --git a/tests/wallpaper_sidecar_test.vala b/tests/wallpaper_sidecar_test.vala
new file mode 100644
index 0000000..b76b12f
--- /dev/null
+++ b/tests/wallpaper_sidecar_test.vala
@@ -0,0 +1,190 @@
+using GLib;
+using Singularity;
+
+private void remove_tree(string path) {
+ try {
+ var dir = Dir.open(path);
+ string? name;
+ while ((name = dir.read_name()) != null) {
+ string child = Path.build_filename(path, name);
+ if (FileUtils.test(child, FileTest.IS_DIR)) remove_tree(child);
+ else FileUtils.unlink(child);
+ }
+ DirUtils.remove(path);
+ } catch (Error e) { error("cleanup: %s", e.message); }
+}
+
+// OCS sidecar with image.title + top-level artist.name -- the canonical shape
+// the helper writes for every imported OCS pack image.
+private string make_ocs_sidecar(string image_filename, string title, string artist_name) {
+ return "{\"schema\":1,\"origin\":\"ocs\",\"pack_id\":\"imported-ocs\"," +
+ "\"image\":{\"file\":\"" + image_filename + "\",\"title\":\"" + title +
+ "\"},\"artist\":{\"name\":\"" + artist_name + "\"}," +
+ "\"provider\":\"pling\",\"source\":{\"ocs_id\":\"123\"}}";
+}
+
+// Bing sidecar as written by ncz-wallpaper-bing.
+private string make_bing_sidecar(string image_filename, string caption, string copyright) {
+ return "{\"provider\":\"bing\",\"image\":{\"file\":\"" + image_filename + "\"}," +
+ "\"caption\":\"" + caption + "\",\"copyright\":\"" + copyright + "\"}";
+}
+
+private string fixture_root;
+private string img_dir;
+private string img_path;
+
+private void setup_fixtures() {
+ try {
+ fixture_root = DirUtils.make_tmp("wallpaper-sidecar-XXXXXX");
+ img_dir = Path.build_filename(fixture_root, "imported-ocs");
+ DirUtils.create(img_dir, 0700);
+ img_path = Path.build_filename(img_dir, "pling-123-01-foo.jpg");
+ FileUtils.set_contents(img_path, "fixture");
+ } catch (Error e) { error("fixture: %s", e.message); }
+}
+
+private void write_sidecar(string data) {
+ string sidecar = Path.build_filename(img_dir, "pling-123-01-foo.json");
+ FileUtils.set_contents(sidecar, data);
+}
+
+private void test_missing_sidecar_clears_attribution() {
+ FileUtils.unlink(Path.build_filename(img_dir, "pling-123-01-foo.json"));
+ // No sidecar at all (plain local photo / drag-drop) -- valid=false
+ // so the caller MUST clear. This is the common case; failing here
+ // would mean every plain local wallpaper shows stale OCS text.
+ var attr = WallpaperSidecar.read(img_path);
+ assert(!attr.valid);
+ assert(attr.title == "");
+ assert(attr.author == "");
+}
+
+private void test_ocs_sidecar_full_metadata() {
+ write_sidecar(make_ocs_sidecar("pling-123-01-foo.jpg",
+ "Mountain Lake Reflections",
+ "Jane Photographer"));
+ var attr = WallpaperSidecar.read(img_path);
+ assert(attr.valid);
+ assert(attr.title == "Mountain Lake Reflections");
+ assert(attr.author == "Jane Photographer");
+}
+
+private void test_ocs_sidecar_partial_title_only() {
+ write_sidecar("{\"origin\":\"ocs\",\"image\":{\"file\":\"pling-123-01-foo.jpg\"," +
+ "\"title\":\"Only Title\"}}");
+ var attr = WallpaperSidecar.read(img_path);
+ assert(attr.valid);
+ assert(attr.title == "Only Title");
+ assert(attr.author == "");
+}
+
+private void test_ocs_sidecar_partial_artist_only() {
+ write_sidecar("{\"origin\":\"ocs\",\"image\":{\"file\":\"pling-123-01-foo.jpg\"}," +
+ "\"artist\":{\"name\":\"Lonely Artist\"}}");
+ var attr = WallpaperSidecar.read(img_path);
+ assert(attr.valid);
+ assert(attr.title == "");
+ assert(attr.author == "Lonely Artist");
+}
+
+private void test_bing_sidecar_copyright_and_caption() {
+ write_sidecar(make_bing_sidecar("bing-en-US-20260818.jpg",
+ "Palmanova",
+ "Marco Zoccheddu/Getty Images"));
+ var attr = WallpaperSidecar.read(img_path);
+ assert(attr.valid);
+ assert(attr.title == "Palmanova");
+ assert(attr.author == "Marco Zoccheddu/Getty Images");
+}
+
+private void test_bing_sidecar_partial_metadata() {
+ // caption without copyright -- the overlay should still credit the
+ // caption and leave the author empty rather than dropping the whole
+ // attribution.
+ write_sidecar("{\"provider\":\"bing\",\"caption\":\"Cliffside\"}");
+ var attr = WallpaperSidecar.read(img_path);
+ assert(attr.valid);
+ assert(attr.title == "Cliffside");
+ assert(attr.author == "");
+}
+
+private void test_unrecognised_origin_clears_attribution() {
+ // Anything other than "ocs" / "bing" is treated as untrusted --
+ // a third-party pack that ships its own sidecar format MUST NOT
+ // leak metadata into the desktop overlay.
+ write_sidecar("{\"origin\":\"unknown\",\"image\":{\"title\":\"Sneaky\"}}");
+ var attr = WallpaperSidecar.read(img_path);
+ assert(!attr.valid);
+ assert(attr.title == "");
+}
+
+private void test_malformed_json_clears_attribution() {
+ // Truncated JSON is not an OCS sidecar. The parser must not crash.
+ FileUtils.set_contents(Path.build_filename(img_dir,
+ "pling-123-01-foo.json"), "{\"origin\":\"ocs");
+ var attr = WallpaperSidecar.read(img_path);
+ assert(!attr.valid);
+}
+
+private void test_non_object_top_level_clears_attribution() {
+ // A sidecar that is a JSON array (the helper never writes this shape
+ // but a future archival flow might) is not a valid OCS / Bing payload.
+ write_sidecar("[{\"origin\":\"ocs\"}]");
+ var attr = WallpaperSidecar.read(img_path);
+ assert(!attr.valid);
+}
+
+private void test_empty_path_clears_attribution() {
+ var attr = WallpaperSidecar.read("");
+ assert(!attr.valid);
+ assert(attr.title == "");
+ assert(attr.author == "");
+}
+
+private void test_no_extension_clears_attribution() {
+ // A wallpaper file without an extension has no obvious "basename"
+ // to derive the sidecar name from -- treat it as no sidecar.
+ string weird = Path.build_filename(img_dir, "noextension");
+ FileUtils.set_contents(weird, "x");
+ var attr = WallpaperSidecar.read(weird);
+ assert(!attr.valid);
+}
+
+public int main(string[] args) {
+ Test.init(ref args);
+ setup_fixtures();
+ Test.add_func("/wallpaper-sidecar/openverse-chooser", () => {
+ write_sidecar("{\"provider\":\"openverse\",\"name\":\"A \",\"attribution\":\"Credit & \",\"license\":\"by-sa\",\"license_version\":\"2.5\",\"page_url\":\"https://example.org/image\"}");
+ var metadata = WallpaperSidecar.read(img_path);
+ assert(metadata.valid);
+ assert(metadata.author == "Credit & ");
+ assert(WallpaperSidecar.display_text(metadata) == "A \nCredit & \nOpenverse · by-sa 2.5");
+ assert(metadata.page_url == "https://example.org/image");
+ assert(WallpaperSidecar.display_text(WallpaperSidecar.read("")) == "");
+ });
+ Test.add_func("/wallpaper-sidecar/unsplash-chooser", () => {
+ write_sidecar("{\"provider\":\"unsplash\",\"name\":\"Mountain\",\"author\":\"Ada\",\"attribution\":\"Photo by Ada on Unsplash\",\"license\":\"Unsplash License\",\"page_url\":\"https://unsplash.com/photos/a\",\"license_url\":\"https://unsplash.com/license\"}");
+ var metadata = WallpaperSidecar.read(img_path);
+ assert(metadata.valid);
+ assert(WallpaperSidecar.display_text(metadata) == "Mountain\nPhoto by Ada on Unsplash\nUnsplash · Unsplash License");
+ });
+ Test.add_func("/wallpaper-sidecar/missing-sidecar", test_missing_sidecar_clears_attribution);
+ Test.add_func("/wallpaper-sidecar/ocs-full-metadata", test_ocs_sidecar_full_metadata);
+ Test.add_func("/wallpaper-sidecar/ocs-title-only", test_ocs_sidecar_partial_title_only);
+ Test.add_func("/wallpaper-sidecar/ocs-artist-only", test_ocs_sidecar_partial_artist_only);
+ Test.add_func("/wallpaper-sidecar/bing-copyright-and-caption", test_bing_sidecar_copyright_and_caption);
+ Test.add_func("/wallpaper-sidecar/bing-partial-metadata", test_bing_sidecar_partial_metadata);
+ Test.add_func("/wallpaper-sidecar/unrecognised-origin", test_unrecognised_origin_clears_attribution);
+ Test.add_func("/wallpaper-sidecar/malformed-json", test_malformed_json_clears_attribution);
+ Test.add_func("/wallpaper-sidecar/non-object-top-level", test_non_object_top_level_clears_attribution);
+ Test.add_func("/wallpaper-sidecar/empty-path", test_empty_path_clears_attribution);
+ Test.add_func("/wallpaper-sidecar/no-extension", test_no_extension_clears_attribution);
+ Test.add_func("/wallpaper/html-attribution", () => {
+ assert(WallpaperSidecar.plain_text("© A & B") == "© A & B");
+ assert(WallpaperSidecar.plain_text("Space <Stars>") == "Space ");
+ assert(WallpaperSidecar.plain_text("Plain © credit") == "Plain © credit");
+ });
+ int ret = Test.run();
+ remove_tree(fixture_root);
+ return ret;
+}