From 36a4297ad453dc25416677efa7a2672e7b6ec13f Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 13:13:25 -0400 Subject: [PATCH 01/11] feat(wallpaper): integrate the OCS browser with the runtime source selector --- meson.build | 32 +- src/components/background/background.vala | 230 +++- src/components/desktop/desktop_icons.vala | 11 +- .../sidebar/pages/desktop_page.vala | 924 ++++++++++++-- .../pages/provider_credential_group.vala | 38 + .../sidebar/pages/wallpaper_ocs_browser.vala | 1117 +++++++++++++++++ .../sidebar/views/settings_view.vala | 19 +- src/core/settings_safety.vala | 23 + src/core/wallpaper_browse_cache.vala | 245 ++++ src/core/wallpaper_collections.vala | 119 +- src/core/wallpaper_manager.vala | 178 ++- src/core/wallpaper_ocs.vala | 532 ++++++++ src/core/wallpaper_provider.vala | 241 ++++ src/core/wallpaper_sidecar.vala | 171 +++ tests/settings_safety_test.vala | 21 + tests/wallpaper_collections_test.vala | 100 ++ tests/wallpaper_ocs_test.vala | 657 ++++++++++ tests/wallpaper_sidecar_test.vala | 190 +++ 18 files changed, 4741 insertions(+), 107 deletions(-) create mode 100644 src/components/sidebar/pages/provider_credential_group.vala create mode 100644 src/components/sidebar/pages/wallpaper_ocs_browser.vala create mode 100644 src/core/settings_safety.vala create mode 100644 src/core/wallpaper_browse_cache.vala create mode 100644 src/core/wallpaper_ocs.vala create mode 100644 src/core/wallpaper_provider.vala create mode 100644 src/core/wallpaper_sidecar.vala create mode 100644 tests/settings_safety_test.vala create mode 100644 tests/wallpaper_ocs_test.vala create mode 100644 tests/wallpaper_sidecar_test.vala diff --git a/meson.build b/meson.build index 8b9866c..4148ba6 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,11 @@ 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_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 +318,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 +491,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 +506,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_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..42668fd 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,68 @@ 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. + // id "all" = "All Markets, No Preference" (immediate write of + // "all"); id "pick" = "Choose Preferred Region..." opens a + // picker dialog. The same dialog object is reused across opens + // and its checkboxes are pre-checked from the file each time it's + // shown. Checking a region here does NOT narrow which markets are + // fetched or shown -- every market is always fetched -- it only + // sets which one wins ties. + private const string BING_MARKETS_ID_ALL = "all"; + private const string BING_MARKETS_ID_PICK = "pick"; + // 13 markets, grouped by region for the picker dialog. Order + // matches the comment block in + // cix-installer/post-install/45-wallpaper-rotator.sh's + // ncz-wallpaper-bing (Americas, Europe, Asia-Pacific). + // [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 Gee.ArrayList bing_markets_regions = new Gee.ArrayList(); + private SelectionRow? bing_markets_row = null; + private ConfirmDialog? bing_markets_dialog = null; + private Gee.ArrayList bing_markets_checkboxes = new Gee.ArrayList(); + 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 +153,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 +196,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 +267,22 @@ namespace Singularity { add_group(preview_group); var grid_group = new PreferencesGroup(_("Wallpapers")); - wallpaper_collections = WallpaperCollections.parse( - WallpaperCollections.default_search_roots()); - - 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; + 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_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 +307,56 @@ 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 two-entry SelectionRow + // 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 -- "All Markets, No Preference" writes "all" + // immediately (today's alphabetical dedup-winner order, kept + // as the neutral default); "Choose Preferred Region..." opens + // a picker dialog with one checkbox per market grouped by + // region, pre-checked from the current file state. 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") }); + bing_markets_options.add(new Singularity.Core.AppSettingOption() { id = BING_MARKETS_ID_PICK, label = _("Choose Preferred Region…") }); + // Initial selection reflects the file: "all" or absent = + // All Markets, anything else = Choose Preferred Region... so + // the dialog opens pre-checked against the actual preference. + bing_markets_row = new SelectionRow.with_options(_("Bing Preferred Region"), bing_markets_options, + bing_markets_file_is_all() ? BING_MARKETS_ID_ALL : BING_MARKETS_ID_PICK); + 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 if (id == BING_MARKETS_ID_PICK) { + open_bing_markets_dialog(); + } + }); + 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 +1924,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 +1965,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 +1976,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 +2068,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 +2259,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 +2613,247 @@ 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}) and bing_markets_regions (unique region order). + // Called once from the constructor before the ComboRow model is + // built. + 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); + bool seen = false; + foreach (string existing in bing_markets_regions) if (existing == cols[2]) { seen = true; break; } + if (!seen) bing_markets_regions.add(cols[2]); + } + } + + // 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"); + } + + // Build (once) and present the preferred-region dialog. The + // checkboxes behave as a single-select group (checking one + // unchecks every other) rather than a genuine multi-select -- + // there is exactly one preferred region, never a subset, so the + // dialog picks ONE market or none at all. The same dialog object + // is reused across opens; the checked state is re-synced against + // the current file state each time, so a user who picks "All + // Markets, No Preference", then "Choose Preferred Region..." + // sees nothing pre-checked, and a user who has a region set sees + // exactly that one checked on reopen. + private void open_bing_markets_dialog() { + if (bing_markets_dialog == null) { + var app = GLib.Application.get_default() as Gtk.Application; + bing_markets_dialog = new ConfirmDialog(app, + _("Choose Preferred Region"), null, + _("Bing always fetches and combines every region's photo of the day. Pick one region here to prefer its caption and credit whenever the same photo is shared across regions. Leave nothing checked for no preference."), + _("Apply"), ConfirmDialog.ActionStyle.SUGGESTED); + + // Build the picker body: one section per region with a + // header label and one Gtk.CheckButton per market. + // The regions array preserves the table order so the + // dialog matches the comment block in + // 45-wallpaper-rotator.sh. + var outer_box = new Gtk.Box(Gtk.Orientation.VERTICAL, 12); + outer_box.margin_top = 8; + outer_box.margin_bottom = 8; + outer_box.margin_start = 4; + outer_box.margin_end = 4; + foreach (string region in bing_markets_regions) { + var region_box = new Gtk.Box(Gtk.Orientation.VERTICAL, 4); + var header = new Gtk.Label(region); + header.halign = Gtk.Align.START; + header.xalign = 0.0f; + header.add_css_class("heading"); + region_box.append(header); + foreach (var row in bing_markets_rows) { + if (row.region != region) continue; + var check = new Gtk.CheckButton.with_label(row.label); + check.set_data("bing-market-code", row.code); + // Single-select: picking one clears every other + // checkbox across all regions, so at most one + // market is ever checked -- a preference is + // singular, unlike the old multi-market subset. + check.toggled.connect(() => { + if (!check.active) return; + foreach (var other in bing_markets_checkboxes) { + if (other != check) other.active = false; + } + }); + region_box.append(check); + bing_markets_checkboxes.add(check); + } + outer_box.append(region_box); + } + var scroll = new Gtk.ScrolledWindow(); + scroll.hscrollbar_policy = Gtk.PolicyType.NEVER; + scroll.vscrollbar_policy = Gtk.PolicyType.AUTOMATIC; + scroll.min_content_height = 240; + scroll.max_content_height = 480; + scroll.propagate_natural_height = true; + scroll.child = outer_box; + bing_markets_dialog.custom_area.append(scroll); + + bing_markets_dialog.response.connect(on_bing_markets_dialog_response); + } + + // Re-sync checkbox state from the file every open. Only the + // FIRST configured code (if any) is checked -- a preference + // is singular now. "all" (or absent) means no preference, so + // nothing is pre-checked. + string[] configured = bing_markets_read_codes(); + string? preferred = configured.length > 0 ? configured[0] : null; + foreach (var check in bing_markets_checkboxes) { + string? code = check.get_data("bing-market-code"); + check.active = (preferred != null && code == preferred); + } + bing_markets_dialog.present(); + } + + // Dialog "Apply" writes the checked region (at most one, thanks + // to the mutual-exclusion wiring above) to + // ~/.config/ncz-wallpaper/bing-markets, or "all" if none is + // checked. "Cancel" (or dismissing the dialog) leaves the file + // alone -- the SelectionRow state already reflects the user's + // last intent ("Choose Preferred Region..."), but no preference + // was changed on disk. + private void on_bing_markets_dialog_response(ConfirmDialog.Response response) { + if (response != ConfirmDialog.Response.PRIMARY) return; + string[] checked_codes = {}; + foreach (var check in bing_markets_checkboxes) { + if (!check.active) continue; + string? code = check.get_data("bing-market-code"); + if (code != null && code != "") checked_codes += code; + } + write_bing_markets_codes(checked_codes); + } + + // Bing market row: 2-letter market code, UI display label, and + // region bucket ("Americas" / "Europe" / "Asia-Pacific") used + // to group checkboxes in the picker dialog. 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 +2867,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 +2895,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 +3022,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 +3038,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 +3139,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..ad92529 --- /dev/null +++ b/src/components/sidebar/pages/wallpaper_ocs_browser.vala @@ -0,0 +1,1117 @@ +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 provider with many usable + // categories fills the grid quickly, but small enough that a single + // provider cannot fork dozens of OCS processes against the helper at + // once. Empirically a provider exposes on the order of one to a few + // dozen usable categories; 4 leaves headroom on each without spiking + // the helper or the UI thread. + private const int CRAWL_WORKERS = 4; + // Hard cap on the total wallpapers merged across every usable category + // for one provider. One page per category at the 50 items/page the + // OCS backend is asked for (see OcsWallpaperProvider) across the + // 45 usable pling categories measured 2026-09-12 yields ~1770 unique + // importable items, so this cap is what actually bounds the grid -- + // it is a real limit, not only a safety belt. Do NOT assume the + // helper's own page default matches: OCS serves 10 per page unless + // asked otherwise, which is what silently held the crawl to ~400. + private const int CRAWL_ITEM_CAP = 1500; + // 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(); + // Filters use stable provider/tag IDs; display labels are separate. + private string active_category_id = ""; + private HashSet active_tag_ids = new HashSet(); + private HashSet known_tag_ids = new HashSet(); + private PreferencesGroup provider_group; + private PreferencesGroup filter_group; + private SelectionRow provider_row; + private SelectionRow category_row; + private SelectionRow tag_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 Cancellable request = new Cancellable(); + private int generation = 0; + private bool loading = false; + private string category_index = ""; + + // 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 every 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. 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(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_all(), 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); + + filter_group = new PreferencesGroup(); + tag_row = new SelectionRow.with_options(_("Tag"), + new Gee.ArrayList()); + filter_group.add_row(tag_row); + add_group(filter_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; + 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); + }); + tag_row.selected.connect((id) => { + if (!updating) on_tag_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 or tag changes nothing visible, so + // the category dropdown disables while loading. The tag dropdown + // stays enabled while loading because it has no tags yet. + 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(); + grid.remove_all(); + item_category.clear(); + known_tag_ids.clear(); + rebuild_tag_row(); + 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) { + bool tag; + add_card(item, "", out tag); + } + 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; + rebuild_tag_row(); + loading = false; + filter_cards(); + status.label = result_status; + if (cards.size > 0) + for (int i = 0; i < 3; i++) thumbnails.begin(i, gen, cancel); + update_controls(); + } + + // Provider selected -> rebuild the category dropdown, then kick off + // the aggregate crawl for every usable category under that provider. + // For Bing the "categories" are actually markets fetched from a + // different helper; the dropdown is the same widget, but the + // underlying command and parser branch. + 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 = ""; + active_tag_ids.clear(); + 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(); + 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 = ""; + active_tag_ids.clear(); + known_tag_ids.clear(); + rebuild_category_row(); + rebuild_tag_row(); + cards.clear(); + grid.remove_all(); + loading = false; + update_controls(); + browse_all.begin(); + } 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 = _("Any category") }); + foreach (var choice in categories) { + options.add(new Singularity.Core.AppSettingOption() { id = choice.id, label = choice.name }); + } + // A filter with one option filters nothing. Bing's de-duplicated + // combined view is a single choice by construction, and an "Any + // category / Combined (All Markets)" dropdown next to a grid that + // is already exactly that is just noise. + category_row.visible = categories.size > 1; + updating = true; + set_choices(category_row, options, active_category_id); + updating = false; + } + + private void on_category_row_selected(string id) { + active_category_id = id; + filter_cards(); + } + + private void on_tag_row_selected(string id) { + active_tag_ids.clear(); + if (id != "") active_tag_ids.add(id); + filter_cards(); + } + + // The aggregate crawl. Pulls a single page from every usable category + // for the selected provider, in parallel bounded by CRAWL_WORKERS, + // then merges results into the grid. Live progress ("Loaded K of N + // categories · M wallpapers so far") is reported through `status` + // each time a category finishes. The Cancellable cuts the rest of + // the crawl off cleanly when the user starts a + // fresh crawl. + 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; + } + 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; + // Snapshot the category list under the current generation so a + // provider change mid-crawl cannot mutate the work queue. + var todo = new ArrayList(); + foreach (var c in categories) todo.add(c.id); + int total = todo.size; + cards.clear(); + grid.remove_all(); + // The category side-map is keyed by item, not by provider, so it + // has to be dropped with the cards it described -- otherwise a + // cache load or a provider switch repopulates cards while stale + // entries from the previous crawl still answer card_matches(). + item_category.clear(); + known_tag_ids.clear(); + rebuild_tag_row(); + // 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. Every filter (category, tag, + // free text) is applied client-side to this same merged list, so + // one cache entry per provider serves every filter combination -- + // changing a filter is a different VIEW, never a different crawl. + if (!force_refresh && load_cached(provider, gen, cancel)) return; + if (total == 0) { + loading = false; + status.label = _("No usable wallpaper categories for this provider."); + update_controls(); + return; + } + loading = true; + // Bing's combined view is a one-entry crawl, so the plural form + // would read "Loading 1 categories…" for every user who has all + // markets enabled. + status.label = total == 1 ? _("Loading 1 category…") + : _("Loading %d categories…").printf(total); + 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. Each worker pulls one id off the shared + // queue at a time until it is drained; an outer fan-out starts + // up to CRAWL_WORKERS at once. + 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_all.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, state.errors.size > 0); + // Three bounded streaming requests, never one worker per tile. + for (int i = 0; i < 3; i++) thumbnails.begin(i, gen, cancel); + } + + // Repaint the grid from the on-disk crawl cache. False means "no + // usable cache" -- no file, a stale one, or one that would not parse + // -- and browse_all() then crawls exactly as it did before. + private bool load_cached(string provider, int gen, Cancellable cancel) { + int64 at = WallpaperBrowseCache.now(); + var cached = WallpaperBrowseCache.load(provider, at); + if (cached == null || cached.entries.size == 0) return false; + foreach (var entry in cached.entries) { + bool card_new_tag; + add_card(entry.item, entry.category, out card_new_tag); + } + loading = false; + rebuild_tag_row(); + 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(); + for (int i = 0; i < 3; i++) thumbnails.begin(i, gen, cancel); + return true; + } + + private void store_cache(string provider, bool partial) { + var snapshot = new ArrayList(); + foreach (var card in cards) + snapshot.add(new WallpaperBrowseCacheEntry(card.item, + item_category.has_key(card.item.key) ? item_category[card.item.key] : "")); + WallpaperBrowseCache.save(provider, 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 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.generation == generation) status.label = _("Loaded %d/%d categories · %d wallpapers (cap reached)").printf(d, state.total, state.item_count); + return Source.REMOVE; + }); + return; + } + string category = state.todo[my_index]; + string? error = null; + bool any_new_tag = false; + 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 (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; + bool card_new_tag; + add_card(item, category, out card_new_tag); + any_new_tag = any_new_tag || card_new_tag; + } + } 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 + tag chip updates live on the main thread. The + // captured `d`/`snap`/`category`/`error`/`any_new_tag` 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 (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 categories · %d wallpapers · %s failed: %s").printf(d, state.total, snap, category, error); + } else { + status.label = _("Loaded %d/%d categories · %d wallpapers so far").printf(d, state.total, snap); + } + if (any_new_tag) rebuild_tag_row(); + 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 rebuild_tag_row() { + var sorted = new ArrayList(); + foreach (var id in known_tag_ids) sorted.add(id); + sorted.sort((a, b) => a.collate(b)); + var options = new Gee.ArrayList(); + options.add(new Singularity.Core.AppSettingOption() { id = "", label = _("Any tag") }); + foreach (var id in sorted) { + options.add(new Singularity.Core.AppSettingOption() { id = id, label = id }); + } + string current_id = active_tag_ids.size > 0 ? active_tag_ids.to_array()[0] : ""; + updating = true; + set_choices(tag_row, options, current_id); + updating = false; + } + + 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, active_category_id, active_tag_ids); + if (card.matches) count++; + } + grid.invalidate_filter(); + if (!loading && !imports.busy) { + if (cards.size == 0) status.label = _("No importable wallpapers for this provider."); + 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; + } + + // AND across all three filter axes: text contains-match against + // name + author, the card's provider-mapped category equals the + // active category (or no active filter), and the card has every + // active tag. The card's own category lives on the item; we look + // it up via the items' positions in the per-category crawl by + // stashing it on the item at add_card time. (Provider+id is the + // existing key; we add a `category_id` side-channel via a + // separate map so the model stays clean.) + // Stash the source category alongside each card so the chip-row + // category filter can match it. Gee.HashMap has no try_get; the + // contains-then-index idiom is the standard substitute. + private HashMap item_category = new HashMap(); + private bool card_matches(WallpaperItem item, string query, string active_category, HashSet active_tags) { + if (active_category != "") { + if (!item_category.has_key(item.key)) return false; + string cat = item_category[item.key]; + if (cat == null || cat != active_category) return false; + } + foreach (var t in active_tags) if (!(t in item.tags)) return false; + 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, string source_category, out bool new_tag_added) { + new_tag_added = false; + 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); + } + item_category.set(item.key, source_category); + foreach (var t in item.tags) if (known_tag_ids.add(t)) new_tag_added = true; + 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); + } + + // Async thumbnail loader, fanned out 3-at-a-time from browse_all(). + // 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 += 3) { + 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 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 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); + 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; + Idle.add(() => { + if (gen == generation && 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..e3fb198 --- /dev/null +++ b/src/core/wallpaper_browse_cache.vala @@ -0,0 +1,245 @@ +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 an aggregate browse crawl, one file per provider. + // + // The browser crawls every usable category of a provider and merges the + // results into one deduplicated list, then filters that list in the UI by + // category, tag and free text. Because every filter is applied client-side + // to the same merged list, the cache key is the PROVIDER alone: changing a + // filter selects a different view of the same cached crawl, never a + // different crawl. Providers that search server-side (Openverse, Unsplash) + // are query- and page-addressed, are not part of the aggregate crawl, and + // already have their own helper-side caching, so they are not cached here. + // + // 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; + } + + // 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"); + } + + public static string path_for(string provider) { + return Path.build_filename(directory(), provider + ".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, int64 at) { + if (!valid_provider(provider)) return null; + return read(path_for(provider), provider, at); + } + + public static bool save(string provider, Gee.List entries, + bool partial, int64 created) { + if (!valid_provider(provider)) return false; + return write(path_for(provider), 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..1d0135b --- /dev/null +++ b/src/core/wallpaper_ocs.vala @@ -0,0 +1,532 @@ +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 { + 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); + string t = element.get_string().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 ONLY when the user's configured markets are the "all" + // sentinel. That is deliberately the helper's judgement, not ours: + // the shell's Bing Markets picker treats an absent config file as + // "all" while the helper treats it as en-US only, so re-deriving the + // mode on this side would disagree with what is actually cached. + // Presence of this id in the helper's own answer is the contract. + 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..a9345d9 --- /dev/null +++ b/src/core/wallpaper_provider.vala @@ -0,0 +1,241 @@ +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_ITEM_CAP of 1500 + // was meant to be the binding limit. Ask for the page size the crawl + // always assumed: 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 = "50"; + 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 now ALWAYS advertises the combined view (it always + // fetches every market; see configured_markets() in + // 45-wallpaper-rotator.sh), so this is unconditionally the ONLY + // browsing axis now -- 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 (see BING_MARKETS_ID_PICK in desktop_page.vala) is + // a PREFERRED region for dedup tie-breaking, not a fetch + // filter, so it has no bearing on what choices() returns here. + var only = WallpaperBing.combined_view(loaded); + combined = only != null; + 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("|]*>"); + 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/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..4aaf2d1 --- /dev/null +++ b/tests/wallpaper_ocs_test.vala @@ -0,0 +1,657 @@ +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); } +} +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; +} From 30ca457f0cc3be6a6a06a8d8e6e0243ac0497135 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 12:30:04 -0400 Subject: [PATCH 02/11] fix(wallpaper): document and surface the Bing helper version-skew that broke OCS browsing Root-caused the operator-reported OCS regression from tonight's fork/ocs-browser-clean deploy (67eeec3) to cixmini. Evidence, not guess: 67eeec3 ("Bing region picker sets a dedup preference, not a fetch filter") redefined what ~/.config/ncz-wallpaper/bing-markets means -- from "the set of markets to fetch" to "which region wins a dedup tie" -- and states in its own commit message that this depends on "the matching cix-installer change" to 45-wallpaper-rotator.sh's ncz-wallpaper-bing helper, which lives in a separate repo this session did not touch or build. WallpaperBing.CONSOLIDATED_ID's own comment in wallpaper_ocs.vala still described the OLD contract ("advertises this id only when the file holds the 'all' sentinel") after 67eeec3 shipped, which is direct evidence the two sides of this cross-repo contract drifted in the same commit that changed one side of it. BingWallpaperProvider.choices() in wallpaper_provider.vala is the OCS browser's Bing tab -- it depends on the SAME file and the SAME helper contract. If the deployed ncz-wallpaper-bing predates 67eeec3's change, it still treats a non-"all" file as a fetch restriction; tonight's redesigned picker (this branch, previous commit) makes picking a single specific region the natural, inviting action, where the old multi-select dialog defaulted to all-13-checked and rarely produced a genuinely single-market file. That shift in what the file typically contains, against a helper that has not also moved to the new contract, is what most plausibly collapses both the rotator's feed AND the OCS browser's Bing results down to one market -- exactly the "OCS is broken" symptom reported live, and NOT something the existing test suite (14/14 passes on 67eeec3 unmodified) can catch, since it never exercises the deployed helper binary. Verification limits: this session did not have access to cix-installer or cixmini (out of scope per instructions), so the deployed helper's actual contract version was not directly confirmed. This is the strongest evidence-backed hypothesis available from the singularity-shell side alone; verifying /usr/local/bin/ncz-wallpaper-bing's behavior on cixmini is the next diagnostic step before another live test. Changes: - wallpaper_ocs.vala: rewrote the CONSOLIDATED_ID comment to describe the current contract and flag the version-skew risk at the exact spot the assumption lives. - wallpaper_provider.vala: BingWallpaperProvider.choices() now logs a warning() when the helper does not advertise the combined view, so a future occurrence is diagnosable from the journal instead of silently presenting a narrowed result set as if Bing just has fewer photos today. Verified: full ninja build (365/365, 0 errors) and meson test (14/14 pass, including wallpaper-ocs) in a debian:forky podman container on ULTRA; zoder (nemotron35 via the TYDEUS reviewer agent) reviewed the combined diff against the SelectionRow/warning() usage, no findings. --- src/core/wallpaper_ocs.vala | 31 +++++++++++++++++++++++++------ src/core/wallpaper_provider.vala | 32 +++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/core/wallpaper_ocs.vala b/src/core/wallpaper_ocs.vala index 1d0135b..c820d2d 100644 --- a/src/core/wallpaper_ocs.vala +++ b/src/core/wallpaper_ocs.vala @@ -219,12 +219,31 @@ namespace Singularity { // exposes it to a browser as one row per unique photograph. // // `ncz-wallpaper-bing markets` advertises this id as an extra first - // line ONLY when the user's configured markets are the "all" - // sentinel. That is deliberately the helper's judgement, not ours: - // the shell's Bing Markets picker treats an absent config file as - // "all" while the helper treats it as en-US only, so re-deriving the - // mode on this side would disagree with what is actually cached. - // Presence of this id in the helper's own answer is the contract. + // 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 diff --git a/src/core/wallpaper_provider.vala b/src/core/wallpaper_provider.vala index a9345d9..3ac4b07 100644 --- a/src/core/wallpaper_provider.vala +++ b/src/core/wallpaper_provider.vala @@ -152,17 +152,35 @@ namespace Singularity { 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 now ALWAYS advertises the combined view (it always - // fetches every market; see configured_markets() in - // 45-wallpaper-rotator.sh), so this is unconditionally the ONLY - // browsing axis now -- per-market browsing is no longer + // 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 (see BING_MARKETS_ID_PICK in desktop_page.vala) is - // a PREFERRED region for dedup tie-breaking, not a fetch - // filter, so it has no bearing on what choices() returns here. + // 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, From c4f3e34f352ff0fc17c2092c4f79edbd1057346e Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 12:30:21 -0400 Subject: [PATCH 03/11] fix(wallpaper): make the Bing region picker inline, not a popup Operator feedback on tonight's cixmini deploy: opening "Choose Preferred Region..." raised a modal ConfirmDialog with a scrollable checkbox list grouped by region and a separate "Apply" button, which reads as out of place next to every other setting on this page -- interval_row, decorations_side_row and the rest are all inline SelectionRow expanders that reveal their choices in place and commit on click. Replaced the dialog with exactly that pattern: bing_markets_row is now a single SelectionRow.with_options() listing "All Markets, No Preference" followed by all 13 markets from BING_MARKETS_TABLE, each labelled " - " (e.g. "Europe - Germany") so the region grouping the old dialog expressed with section headers survives as label text, and SelectionRow's own search entry (which appears past 5 items) lets a region or market name filter the now-14-entry list. Clicking any row is a single action: SelectionRow always collapses its own expander and fires `selected` with exactly the one id chosen, matching the operator's "inline within the settings/control-panel row itself" request with no new widget class and no popup. Removed the now-dead popup implementation entirely: bing_markets_dialog, bing_markets_checkboxes, open_bing_markets_dialog(), on_bing_markets_dialog_response(), the BING_MARKETS_ID_PICK sentinel, and the bing_markets_regions bookkeeping list that only existed to group the dialog's checkboxes (the flat SelectionRow needs no group index; region is just a label prefix now). Net -62 lines. Verified: full ninja build (365/365, 0 errors) and meson test (14/14 pass) in a debian:forky podman container on ULTRA; ldd confirms every library resolves under /usr/lib/aarch64-linux-gnu (Debian multiarch, zero /lib64 references) -- no Fedora-vs-Debian libc mismatch. zoder (nemotron35 via the TYDEUS reviewer agent) reviewed the SelectionRow usage, the current-value fallback logic for a stale/unrecognized market code in the config file, and the removed-code cleanup; no findings. --- .../sidebar/pages/desktop_page.vala | 206 ++++++------------ 1 file changed, 72 insertions(+), 134 deletions(-) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index 42668fd..b5777b2 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -61,19 +61,21 @@ namespace Singularity { // 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. - // id "all" = "All Markets, No Preference" (immediate write of - // "all"); id "pick" = "Choose Preferred Region..." opens a - // picker dialog. The same dialog object is reused across opens - // and its checkboxes are pre-checked from the file each time it's - // shown. Checking a region here does NOT narrow which markets are - // fetched or shown -- every market is always fetched -- it only - // sets which one wins ties. + // + // 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"; - private const string BING_MARKETS_ID_PICK = "pick"; - // 13 markets, grouped by region for the picker dialog. Order - // matches the comment block in - // cix-installer/post-install/45-wallpaper-rotator.sh's - // ncz-wallpaper-bing (Americas, Europe, Asia-Pacific). + // 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" @@ -89,10 +91,7 @@ namespace Singularity { + "|zh-CN\tChina\tAsia-Pacific" + "|ko-KR\tSouth Korea\tAsia-Pacific"; private Gee.ArrayList bing_markets_rows = new Gee.ArrayList(); - private Gee.ArrayList bing_markets_regions = new Gee.ArrayList(); private SelectionRow? bing_markets_row = null; - private ConfirmDialog? bing_markets_dialog = null; - private Gee.ArrayList bing_markets_checkboxes = new Gee.ArrayList(); private bool bing_markets_updating = false; // Lower-case an ASCII string. Vala's GLib string has no public @@ -321,18 +320,30 @@ namespace Singularity { settings.set_boolean("show-wallpaper-attribution", attribution_row.switch_btn.active); }); - // Bing preferred-region selector. The two-entry SelectionRow - // matches ncz-wallpaper-bing's existing "all" sentinel in + // 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 -- "All Markets, No Preference" writes "all" - // immediately (today's alphabetical dedup-winner order, kept - // as the neutral default); "Choose Preferred Region..." opens - // a picker dialog with one checkbox per market grouped by - // region, pre-checked from the current file state. We don't + // 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 @@ -340,19 +351,33 @@ namespace Singularity { 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") }); - bing_markets_options.add(new Singularity.Core.AppSettingOption() { id = BING_MARKETS_ID_PICK, label = _("Choose Preferred Region…") }); - // Initial selection reflects the file: "all" or absent = - // All Markets, anything else = Choose Preferred Region... so - // the dialog opens pre-checked against the actual 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_file_is_all() ? BING_MARKETS_ID_ALL : BING_MARKETS_ID_PICK); + 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 if (id == BING_MARKETS_ID_PICK) { - open_bing_markets_dialog(); + } else { + write_bing_markets_codes({id}); } }); grid_group.add_row(bing_markets_row); @@ -2620,18 +2645,14 @@ namespace Singularity { // ------------------------------------------------------------------------- // Parse BING_MARKETS_TABLE into bing_markets_rows ({code, label, - // region}) and bing_markets_regions (unique region order). - // Called once from the constructor before the ComboRow model is - // built. + // 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); - bool seen = false; - foreach (string existing in bing_markets_regions) if (existing == cols[2]) { seen = true; break; } - if (!seen) bing_markets_regions.add(cols[2]); } } @@ -2740,108 +2761,25 @@ namespace Singularity { write_bing_markets_contents(string.joinv(" ", codes) + "\n"); } - // Build (once) and present the preferred-region dialog. The - // checkboxes behave as a single-select group (checking one - // unchecks every other) rather than a genuine multi-select -- - // there is exactly one preferred region, never a subset, so the - // dialog picks ONE market or none at all. The same dialog object - // is reused across opens; the checked state is re-synced against - // the current file state each time, so a user who picks "All - // Markets, No Preference", then "Choose Preferred Region..." - // sees nothing pre-checked, and a user who has a region set sees - // exactly that one checked on reopen. - private void open_bing_markets_dialog() { - if (bing_markets_dialog == null) { - var app = GLib.Application.get_default() as Gtk.Application; - bing_markets_dialog = new ConfirmDialog(app, - _("Choose Preferred Region"), null, - _("Bing always fetches and combines every region's photo of the day. Pick one region here to prefer its caption and credit whenever the same photo is shared across regions. Leave nothing checked for no preference."), - _("Apply"), ConfirmDialog.ActionStyle.SUGGESTED); - - // Build the picker body: one section per region with a - // header label and one Gtk.CheckButton per market. - // The regions array preserves the table order so the - // dialog matches the comment block in - // 45-wallpaper-rotator.sh. - var outer_box = new Gtk.Box(Gtk.Orientation.VERTICAL, 12); - outer_box.margin_top = 8; - outer_box.margin_bottom = 8; - outer_box.margin_start = 4; - outer_box.margin_end = 4; - foreach (string region in bing_markets_regions) { - var region_box = new Gtk.Box(Gtk.Orientation.VERTICAL, 4); - var header = new Gtk.Label(region); - header.halign = Gtk.Align.START; - header.xalign = 0.0f; - header.add_css_class("heading"); - region_box.append(header); - foreach (var row in bing_markets_rows) { - if (row.region != region) continue; - var check = new Gtk.CheckButton.with_label(row.label); - check.set_data("bing-market-code", row.code); - // Single-select: picking one clears every other - // checkbox across all regions, so at most one - // market is ever checked -- a preference is - // singular, unlike the old multi-market subset. - check.toggled.connect(() => { - if (!check.active) return; - foreach (var other in bing_markets_checkboxes) { - if (other != check) other.active = false; - } - }); - region_box.append(check); - bing_markets_checkboxes.add(check); - } - outer_box.append(region_box); - } - var scroll = new Gtk.ScrolledWindow(); - scroll.hscrollbar_policy = Gtk.PolicyType.NEVER; - scroll.vscrollbar_policy = Gtk.PolicyType.AUTOMATIC; - scroll.min_content_height = 240; - scroll.max_content_height = 480; - scroll.propagate_natural_height = true; - scroll.child = outer_box; - bing_markets_dialog.custom_area.append(scroll); - - bing_markets_dialog.response.connect(on_bing_markets_dialog_response); - } - - // Re-sync checkbox state from the file every open. Only the - // FIRST configured code (if any) is checked -- a preference - // is singular now. "all" (or absent) means no preference, so - // nothing is pre-checked. - string[] configured = bing_markets_read_codes(); - string? preferred = configured.length > 0 ? configured[0] : null; - foreach (var check in bing_markets_checkboxes) { - string? code = check.get_data("bing-market-code"); - check.active = (preferred != null && code == preferred); - } - bing_markets_dialog.present(); - } - - // Dialog "Apply" writes the checked region (at most one, thanks - // to the mutual-exclusion wiring above) to - // ~/.config/ncz-wallpaper/bing-markets, or "all" if none is - // checked. "Cancel" (or dismissing the dialog) leaves the file - // alone -- the SelectionRow state already reflects the user's - // last intent ("Choose Preferred Region..."), but no preference - // was changed on disk. - private void on_bing_markets_dialog_response(ConfirmDialog.Response response) { - if (response != ConfirmDialog.Response.PRIMARY) return; - string[] checked_codes = {}; - foreach (var check in bing_markets_checkboxes) { - if (!check.active) continue; - string? code = check.get_data("bing-market-code"); - if (code != null && code != "") checked_codes += code; - } - write_bing_markets_codes(checked_codes); - } + // 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 - // to group checkboxes in the picker dialog. Plain GLib.Object - // rather than a struct so it can be stored in a Gee.ArrayList - // (Vala disallows array types as generic type arguments). + // 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; } From 5e38fddbbb965a0a47dea613ec18b514931547a8 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 15:08:31 -0400 Subject: [PATCH 04/11] fix(wallpaper): stop truncating the OCS aggregate feed Request the OCS server maximum of 100 items in the existing single page per category. This keeps the crawl at the same 45 requests and within the same per-category timeout budget. A forced live crawl against api.pling.com from the CIX Sky1 target on 2026-09-13 returned 3,485 importable results across all 45 usable pling categories: 3,352 unique item IDs after removing 133 cross-category duplicates, with zero category failures in 14.19 seconds. Raise the aggregate safety cap from 1,500 to 4,000, 648 items (19%) above the measured unique total, so it no longer truncates the current feed. Gtk.FlowBox retains one real child hierarchy per card and the browser eagerly decodes every 344x208 thumbnail. A target-side representative benchmark settled at 703 MiB RSS for 1,500 cards and 1.35 GiB for 3,352; construction took 0.43s and 0.92s respectively. That linear cost is worth a future virtualized/lazy-loading change, but is not a present resource limit on the 64 GiB Sky1 system (59 GiB available during the test). Verified after rebasing the concurrent wallpaper history/favorites work: clean full ninja build (376/376, 0 errors) and meson test (16/16 pass) in a debian:forky podman container on ULTRA. Assisted-by: Codex:gpt-5 AI-Scope: Measured the live page-size-100 OCS aggregate and target-side FlowBox memory behavior, raised the page size and crawl safety cap, and verified the full build and test suite. --- .../sidebar/pages/wallpaper_ocs_browser.vala | 18 +++++++++--------- src/core/wallpaper_provider.vala | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/sidebar/pages/wallpaper_ocs_browser.vala b/src/components/sidebar/pages/wallpaper_ocs_browser.vala index ad92529..8226b8d 100644 --- a/src/components/sidebar/pages/wallpaper_ocs_browser.vala +++ b/src/components/sidebar/pages/wallpaper_ocs_browser.vala @@ -38,15 +38,15 @@ namespace Singularity.Shell { // dozen usable categories; 4 leaves headroom on each without spiking // the helper or the UI thread. private const int CRAWL_WORKERS = 4; - // Hard cap on the total wallpapers merged across every usable category - // for one provider. One page per category at the 50 items/page the - // OCS backend is asked for (see OcsWallpaperProvider) across the - // 45 usable pling categories measured 2026-09-12 yields ~1770 unique - // importable items, so this cap is what actually bounds the grid -- - // it is a real limit, not only a safety belt. Do NOT assume the - // helper's own page default matches: OCS serves 10 per page unless - // asked otherwise, which is what silently held the crawl to ~400. - private const int CRAWL_ITEM_CAP = 1500; + // Safety cap on the total wallpapers merged across every usable + // category for one provider. One page per category at the server's + // maximum 100 items/page (see OcsWallpaperProvider) across pling's + // 45 usable categories measured 2026-09-13 yields 3485 results and + // 3352 unique importable items. Keep the cap above that real total so + // it guards against unexpected provider growth instead of truncating + // today's feed. Do NOT assume the helper's own page default matches: + // OCS serves 10 per page unless asked otherwise. + private const int CRAWL_ITEM_CAP = 4000; // 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. diff --git a/src/core/wallpaper_provider.vala b/src/core/wallpaper_provider.vala index 3ac4b07..0650644 100644 --- a/src/core/wallpaper_provider.vala +++ b/src/core/wallpaper_provider.vala @@ -83,9 +83,9 @@ namespace Singularity { // 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_ITEM_CAP of 1500 - // was meant to be the binding limit. Ask for the page size the crawl - // always assumed: still ONE request per category, so the request + // 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. @@ -98,7 +98,7 @@ namespace Singularity { // 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 = "50"; + 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; } } From 790d50a2ccf5ec9365993afb7048a0b6eca0fdd5 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 15:50:44 -0400 Subject: [PATCH 05/11] perf(wallpaper): cache OCS thumbnails on disk, raise fetch concurrency OCS previews were downloaded again on every page visit, while three strided loader lanes made populating large filtered grids unnecessarily slow. Persist successful preview responses in a bounded atomic LRU cache and use eight synchronized loader lanes so revisits avoid network work and uncached grids fill faster. AI-Scope: Implemented the upstream-specified thumbnail cache, browser integration, concurrency change, and verification. Assisted-by: Claude Code:claude-sonnet-5 AI-scope: Implemented the dispatched cache and concurrency changes from the upstream diagnosis and specification. --- meson.build | 3 +- .../sidebar/pages/wallpaper_ocs_browser.vala | 86 ++++++----- src/core/wallpaper_thumbnail_cache.vala | 145 ++++++++++++++++++ 3 files changed, 198 insertions(+), 36 deletions(-) create mode 100644 src/core/wallpaper_thumbnail_cache.vala diff --git a/meson.build b/meson.build index 4148ba6..d0b09a7 100644 --- a/meson.build +++ b/meson.build @@ -289,6 +289,7 @@ singularity_core_sources = files( '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', @@ -515,7 +516,7 @@ wallpaper_rotator_test = executable('wallpaper-rotator-test', 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_provider.vala', 'src/core/wallpaper_collections.vala', 'tests/wallpaper_ocs_test.vala'], + 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) diff --git a/src/components/sidebar/pages/wallpaper_ocs_browser.vala b/src/components/sidebar/pages/wallpaper_ocs_browser.vala index 8226b8d..947939e 100644 --- a/src/components/sidebar/pages/wallpaper_ocs_browser.vala +++ b/src/components/sidebar/pages/wallpaper_ocs_browser.vala @@ -47,6 +47,7 @@ namespace Singularity.Shell { // today's feed. Do NOT assume the helper's own page default matches: // OCS serves 10 per page unless asked otherwise. private const int CRAWL_ITEM_CAP = 4000; + private const int THUMBNAIL_FETCH_LANES = 8; // 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. @@ -71,6 +72,7 @@ namespace Singularity.Shell { 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; @@ -435,7 +437,7 @@ namespace Singularity.Shell { filter_cards(); status.label = result_status; if (cards.size > 0) - for (int i = 0; i < 3; i++) thumbnails.begin(i, gen, cancel); + for (int i = 0; i < THUMBNAIL_FETCH_LANES; i++) thumbnails.begin(i, gen, cancel); update_controls(); } @@ -658,8 +660,8 @@ namespace Singularity.Shell { // it a much shorter TTL than a clean one. if (!cancel.is_cancelled() && cards.size > 0) store_cache(provider, state.errors.size > 0); - // Three bounded streaming requests, never one worker per tile. - for (int i = 0; i < 3; i++) thumbnails.begin(i, gen, cancel); + // Bounded streaming requests, never one worker per tile. + for (int i = 0; i < THUMBNAIL_FETCH_LANES; i++) thumbnails.begin(i, gen, cancel); } // Repaint the grid from the on-disk crawl cache. False means "no @@ -682,7 +684,7 @@ namespace Singularity.Shell { status.label = _("%d wallpapers · %s · Refresh for new uploads").printf( cards.size, cache_age(cached.age(at))); update_controls(); - for (int i = 0; i < 3; i++) thumbnails.begin(i, gen, cancel); + for (int i = 0; i < THUMBNAIL_FETCH_LANES; i++) thumbnails.begin(i, gen, cancel); return true; } @@ -967,7 +969,7 @@ namespace Singularity.Shell { cards.add(card); } - // Async thumbnail loader, fanned out 3-at-a-time from browse_all(). + // Async thumbnail loader, fanned out from browse_all(). // Two source paths: // * Bing items: thumbnail_path is a local file (helper pre- // downloaded the 400x240 JPEG before the `list` response was @@ -985,7 +987,7 @@ namespace Singularity.Shell { // 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 += 3) { + for (int i = start; i < cards.size && gen == generation && !cancel.is_cancelled(); i += THUMBNAIL_FETCH_LANES) { var card = cards[i]; Gdk.Pixbuf? pixbuf = null; if (card.item.thumbnail_path != "") { @@ -1028,39 +1030,53 @@ namespace Singularity.Shell { // OCS Soup path. string url = card.item.preview; if (!url.has_prefix("https://") && !url.has_prefix("http://")) continue; - 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 input = new MemoryInputStream.from_bytes(ByteArray.free_to_bytes((owned) bytes)); + 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; } - } 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 (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 (skip_card) continue; } if (pixbuf == null) continue; if (gen != generation || cancel.is_cancelled()) continue; 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; + } + } +} From 3d3aa6584a326a09281afe903c1c88a83f6fb199 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 15:56:23 -0400 Subject: [PATCH 06/11] perf(wallpaper): page thumbnail loading by viewport The 4000-item aggregate eagerly decoded every FlowBox thumbnail, driving measured resident memory to roughly 1.35 GiB for 3352 cards even though only a screenful was visible. Queue only cards intersecting the viewport plus a bounded prefetch margin, and evict decoded paintables outside a wider hysteresis margin so scrolling through the full feed cannot retain every texture. Assisted-by: Claude Code:claude-sonnet-5 AI-scope: Implemented viewport-driven thumbnail scheduling and in-memory texture eviction while preserving the existing cache and cancellation machinery. --- .../sidebar/pages/wallpaper_ocs_browser.vala | 75 +++++++++++++++++-- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/src/components/sidebar/pages/wallpaper_ocs_browser.vala b/src/components/sidebar/pages/wallpaper_ocs_browser.vala index 947939e..1ed38f7 100644 --- a/src/components/sidebar/pages/wallpaper_ocs_browser.vala +++ b/src/components/sidebar/pages/wallpaper_ocs_browser.vala @@ -48,6 +48,12 @@ namespace Singularity.Shell { // OCS serves 10 per page unless asked otherwise. private const int CRAWL_ITEM_CAP = 4000; private const int THUMBNAIL_FETCH_LANES = 8; + // 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. @@ -57,6 +63,8 @@ namespace Singularity.Shell { 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(); // Filters use stable provider/tag IDs; display labels are separate. private string active_category_id = ""; private HashSet active_tag_ids = new HashSet(); @@ -230,6 +238,7 @@ namespace Singularity.Shell { 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); @@ -408,6 +417,7 @@ namespace Singularity.Shell { var cancel = request; loading = true; cards.clear(); + reset_thumbnail_loading(); grid.remove_all(); item_category.clear(); known_tag_ids.clear(); @@ -436,8 +446,7 @@ namespace Singularity.Shell { loading = false; filter_cards(); status.label = result_status; - if (cards.size > 0) - for (int i = 0; i < THUMBNAIL_FETCH_LANES; i++) thumbnails.begin(i, gen, cancel); + queue_viewport_thumbnails(); update_controls(); } @@ -473,6 +482,7 @@ namespace Singularity.Shell { 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."); @@ -511,6 +521,7 @@ namespace Singularity.Shell { rebuild_category_row(); rebuild_tag_row(); cards.clear(); + reset_thumbnail_loading(); grid.remove_all(); loading = false; update_controls(); @@ -580,6 +591,7 @@ namespace Singularity.Shell { foreach (var c in categories) todo.add(c.id); int total = todo.size; cards.clear(); + reset_thumbnail_loading(); grid.remove_all(); // The category side-map is keyed by item, not by provider, so it // has to be dropped with the cards it described -- otherwise a @@ -660,8 +672,7 @@ namespace Singularity.Shell { // it a much shorter TTL than a clean one. if (!cancel.is_cancelled() && cards.size > 0) store_cache(provider, state.errors.size > 0); - // Bounded streaming requests, never one worker per tile. - for (int i = 0; i < THUMBNAIL_FETCH_LANES; i++) thumbnails.begin(i, gen, cancel); + queue_viewport_thumbnails(); } // Repaint the grid from the on-disk crawl cache. False means "no @@ -684,7 +695,7 @@ namespace Singularity.Shell { status.label = _("%d wallpapers · %s · Refresh for new uploads").printf( cards.size, cache_age(cached.age(at))); update_controls(); - for (int i = 0; i < THUMBNAIL_FETCH_LANES; i++) thumbnails.begin(i, gen, cancel); + queue_viewport_thumbnails(); return true; } @@ -849,6 +860,7 @@ namespace Singularity.Shell { 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 provider."); else if (count == 0) status.label = _("No matches among loaded wallpapers. Clear the filter or refresh."); @@ -969,7 +981,54 @@ namespace Singularity.Shell { cards.add(card); } - // Async thumbnail loader, fanned out from browse_all(). + 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 @@ -988,6 +1047,7 @@ namespace Singularity.Shell { // 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 != "") { @@ -1086,8 +1146,9 @@ namespace Singularity.Shell { // 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 && captured_card.card != null) + 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; }); From fa466e0bd2b14f454a368216757a4bb293ac7949 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 15:58:00 -0400 Subject: [PATCH 07/11] perf(wallpaper): revalidate stale browse cache Expired metadata cache files previously withheld a usable grid until every live category request completed, making normal page opens unnecessarily dependent on network latency. Paint any valid persisted snapshot immediately, re-crawl through the existing bounded worker pool into detached metadata, and atomically repaint only when background revalidation yields usable results. Assisted-by: Claude Code:claude-sonnet-5 AI-scope: Implemented stale-while-revalidate metadata loading with generation-safe background crawling and a no-empty-frame result swap. --- .../sidebar/pages/wallpaper_ocs_browser.vala | 114 ++++++++++++++++-- 1 file changed, 101 insertions(+), 13 deletions(-) diff --git a/src/components/sidebar/pages/wallpaper_ocs_browser.vala b/src/components/sidebar/pages/wallpaper_ocs_browser.vala index 1ed38f7..66dd0eb 100644 --- a/src/components/sidebar/pages/wallpaper_ocs_browser.vala +++ b/src/components/sidebar/pages/wallpaper_ocs_browser.vala @@ -86,6 +86,8 @@ namespace Singularity.Shell { 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 @@ -606,7 +608,14 @@ namespace Singularity.Shell { // free text) is applied client-side to this same merged list, so // one cache entry per provider serves every filter combination -- // changing a filter is a different VIEW, never a different crawl. - if (!force_refresh && load_cached(provider, gen, cancel)) return; + if (!force_refresh) { + var cache_result = load_cached(provider, gen, cancel); + if (cache_result == CacheLoadResult.FRESH) return; + if (cache_result == CacheLoadResult.STALE) { + revalidate_cached.begin(selected_provider, provider, todo, gen, cancel); + return; + } + } if (total == 0) { loading = false; status.label = _("No usable wallpaper categories for this provider."); @@ -675,13 +684,23 @@ namespace Singularity.Shell { queue_viewport_thumbnails(); } - // Repaint the grid from the on-disk crawl cache. False means "no - // usable cache" -- no file, a stale one, or one that would not parse - // -- and browse_all() then crawls exactly as it did before. - private bool load_cached(string provider, int gen, Cancellable cancel) { + // Repaint from any valid on-disk snapshot. Freshness controls whether + // browse_all() is finished or starts a silent background revalidate; + // it never controls whether already persisted metadata can be shown. + private CacheLoadResult load_cached(string provider, int gen, Cancellable cancel) { int64 at = WallpaperBrowseCache.now(); - var cached = WallpaperBrowseCache.load(provider, at); - if (cached == null || cached.entries.size == 0) return false; + WallpaperBrowseCache? cached = null; + string path = WallpaperBrowseCache.path_for(provider); + 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; foreach (var entry in cached.entries) { bool card_new_tag; add_card(entry.item, entry.category, out card_new_tag); @@ -696,7 +715,60 @@ namespace Singularity.Shell { cards.size, cache_age(cached.age(at))); update_controls(); queue_viewport_thumbnails(); - return true; + return cached.fresh(at) ? CacheLoadResult.FRESH : CacheLoadResult.STALE; + } + + // 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, + 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 produced no usable results", provider); + return; + } + WallpaperBrowseCache.save(provider, state.results, + state.errors.size > 0, WallpaperBrowseCache.now()); + cards.clear(); + reset_thumbnail_loading(); + grid.remove_all(); + item_category.clear(); + known_tag_ids.clear(); + foreach (var entry in state.results) { + bool card_new_tag; + add_card(entry.item, entry.category, out card_new_tag); + } + rebuild_tag_row(); + 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, bool partial) { @@ -720,6 +792,10 @@ namespace Singularity.Shell { // 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; @@ -765,7 +841,8 @@ namespace Singularity.Shell { int d; try { d = ++state.done_count; } finally { state.count_lock.unlock(); } Idle.add(() => { - if (state.generation == generation) status.label = _("Loaded %d/%d categories · %d wallpapers (cap reached)").printf(d, state.total, state.item_count); + if (!state.background && state.generation == generation) + status.label = _("Loaded %d/%d categories · %d wallpapers (cap reached)").printf(d, state.total, state.item_count); return Source.REMOVE; }); return; @@ -779,7 +856,11 @@ namespace Singularity.Shell { var items = result.items; if (result.warning != "") error = _(result.warning); foreach (var item in items) { - if (has_card(item)) continue; + 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(); @@ -794,9 +875,15 @@ namespace Singularity.Shell { state.count_lock.unlock(); } if (overflow) break; - bool card_new_tag; - add_card(item, category, out card_new_tag); - any_new_tag = any_new_tag || card_new_tag; + 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 { + bool card_new_tag; + add_card(item, category, out card_new_tag); + any_new_tag = any_new_tag || card_new_tag; + } } } catch (Error e) { if (e is GLib.IOError.CANCELLED) return; @@ -813,6 +900,7 @@ namespace Singularity.Shell { // 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. From 7b304feb544c0d83e1563042b3a806a943a7a1d3 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 16:54:25 -0400 Subject: [PATCH 08/11] fix(wallpapers): sanitize OCS tags Uploader-supplied tag text is rendered directly in filter controls, so invisible format and control characters can corrupt nearby UI while oversized labels can break the row layout. Sanitize and cap tags at the shared parser boundary so every provider path receives the same safe, deduplicated values. Assisted-by: Claude Code:claude-sonnet-5 AI-Scope: Implemented Unicode category filtering, tag truncation, and focused parser tests from the requested OCS tag-hardening requirements. --- src/core/wallpaper_ocs.vala | 16 +++++++++++++++- tests/wallpaper_ocs_test.vala | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/core/wallpaper_ocs.vala b/src/core/wallpaper_ocs.vala index c820d2d..b2a9cf3 100644 --- a/src/core/wallpaper_ocs.vala +++ b/src/core/wallpaper_ocs.vala @@ -49,6 +49,8 @@ namespace Singularity { // 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"); @@ -98,7 +100,19 @@ namespace Singularity { 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); - string t = element.get_string().strip(); + 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(); diff --git a/tests/wallpaper_ocs_test.vala b/tests/wallpaper_ocs_test.vala index 4aaf2d1..15a7d6f 100644 --- a/tests/wallpaper_ocs_test.vala +++ b/tests/wallpaper_ocs_test.vala @@ -40,6 +40,26 @@ private void test_tags() { 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); } From ed7166c839b56b7fe27dd5f724388ae51686ec72 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 16:55:58 -0400 Subject: [PATCH 09/11] feat(wallpapers): add clear filters action Resetting category, tag, and search independently makes returning to the full wallpaper view unnecessarily tedious. Add one visible action that synchronizes all three controls under the existing update guard and refreshes the grid only after the complete reset. Assisted-by: Claude Code:claude-sonnet-5 AI-Scope: Added the filter-group reset button, guarded UI synchronization, and single-pass grid filtering requested for the wallpaper browser. --- .../sidebar/pages/wallpaper_ocs_browser.vala | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/components/sidebar/pages/wallpaper_ocs_browser.vala b/src/components/sidebar/pages/wallpaper_ocs_browser.vala index 66dd0eb..8340865 100644 --- a/src/components/sidebar/pages/wallpaper_ocs_browser.vala +++ b/src/components/sidebar/pages/wallpaper_ocs_browser.vala @@ -180,7 +180,9 @@ namespace Singularity.Shell { var search_group = new PreferencesGroup(); search_row = new EntryRow(_("Filter loaded wallpapers")); - search_row.entry_changed.connect(filter_cards); + 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_all(), and it sets NCZ_WALLPAPER_REFRESH for the @@ -202,7 +204,12 @@ namespace Singularity.Shell { category_group.add_row(category_row); add_group(category_group); - filter_group = new PreferencesGroup(); + filter_group = new PreferencesGroup(_("Filters")); + var clear_filters_button = new Button.with_label(_("Clear Filters")); + clear_filters_button.has_frame = false; + clear_filters_button.valign = Align.CENTER; + clear_filters_button.clicked.connect(clear_filters); + filter_group.add_header_suffix(clear_filters_button); tag_row = new SelectionRow.with_options(_("Tag"), new Gee.ArrayList()); filter_group.add_row(tag_row); @@ -547,9 +554,21 @@ namespace Singularity.Shell { // category / Combined (All Markets)" dropdown next to a grid that // is already exactly that is just noise. 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 clear_filters() { + updating = true; + active_category_id = ""; + active_tag_ids.clear(); + rebuild_category_row(); + rebuild_tag_row(); + if (search_row != null) search_row.text = ""; updating = false; + filter_cards(); } private void on_category_row_selected(string id) { @@ -935,9 +954,10 @@ namespace Singularity.Shell { options.add(new Singularity.Core.AppSettingOption() { id = id, label = id }); } string current_id = active_tag_ids.size > 0 ? active_tag_ids.to_array()[0] : ""; + bool was_updating = updating; updating = true; set_choices(tag_row, options, current_id); - updating = false; + updating = was_updating; } private void filter_cards() { From c18a1f6f209bfb5224561001b859cc8f1e220129 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 13 Sep 2026 21:58:26 -0400 Subject: [PATCH 10/11] perf(wallpaper): batch OCS card construction to stop the repaint freeze Tonight's OCS browser work (79a885a raising the aggregate feed to its real ~3358-item size, cc5c861/b9d8a8b/e5ee222 thumbnail caching, viewport paging and stale-revalidation) made the page noticeably slower to open, reported live on cixmini after a fresh desktop-session restart. Root cause, isolated with real timing instrumentation against the actual 3358-item OCS cache on CIX Sky1 target hardware (cixmini): load_cached() built every WallpaperCard widget hierarchy for the cached aggregate in one unbroken synchronous main-thread loop. PERF load_cached: read=1.5ms parse=96.6ms entries=3358 PERF load_cached: add_card_loop=2139.6ms filter_cards=57.0ms TOTAL=2295.8ms JSON parsing was not the bottleneck (96.6ms); GTK widget construction was (2139.6ms, ~0.64ms/card), and it ran on every cache repaint -- i.e. on every page open, and again whenever a stale cache silently revalidates in the background (revalidate_cached() has the identical pattern). Viewport-paged thumbnail *decoding* (b9d8a8b) does not help here: add_card() still builds a full card/button/badge widget subtree for every item regardless of visibility, so raising the real feed size from an effective ~400 items (pre-79a885a page-size-10 default) to 3358 multiplied this synchronous block by roughly 8x. Fix: build the card list in bounded batches (CARD_BUILD_BATCH_SIZE=150), yielding to the main loop between batches via the same Idle.add() pattern already used elsewhere in this file for polling loops, so the compositor stays responsive throughout. queue_viewport_thumbnails() runs after every batch so visible rows start decoding as soon as they exist. load_cached() became async (single call site in browse_all() updated to yield it). revalidate_cached() keeps its no visible empty grid promise because the first batch still lands in the same synchronous continuation as the clear() that precedes it -- only the remaining batches spread across further main-loop turns. Measured after the fix, same real cache/hardware: PERF-FIX load_cached: read+parse=94.8ms entries=3358 PERF-FIX batch: processed=150 batch_build=48.87ms ... (22 batches, worst observed 129.55ms) PERF-FIX load_cached: populate_cards_batched=4566.6ms TOTAL=4719.4ms Worst single main-loop-turn block dropped from 2139.6ms to 129.55ms (>16x). Total wall-clock to fully populate the grid from cache went up (~4.7s vs ~2.3s) from Idle-scheduling overhead across ~22 batches; that tradeoff is deliberate -- the reported complaint was the shell appearing to hang, not total elapsed time, and the grid now visibly fills in instead of freezing then appearing all at once. Verified: clean ninja build (228/228) and meson test (11/11 pass) in a debian:forky podman container on ULTRA. Timing measured with temporary stderr instrumentation against a real 3358-item OCS cache (copied from the live cixmini session) driven through the actual settings page code path in an isolated headless nested Wayland session (WLR_BACKENDS=headless labwc) on cixmini target hardware, not a synthetic benchmark; all diagnostic instrumentation was removed before this commit. zoder (reviewer agent, cross-family from this session) reviewed the diff with file access to the real working tree and returned APPROVE after verifying gen/cancellation handling, the async conversion's single call site, closure/ownership correctness in the Idle.add() pattern, and the no empty grid invariant, each against verbatim-quoted lines. Assisted-by: Claude Code:claude-sonnet-5 AI-Scope: Root-caused the freeze with real timing instrumentation on target hardware, implemented and verified the batched-construction fix, and ran the review gate. --- .../sidebar/pages/wallpaper_ocs_browser.vala | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/src/components/sidebar/pages/wallpaper_ocs_browser.vala b/src/components/sidebar/pages/wallpaper_ocs_browser.vala index 8340865..4cd2649 100644 --- a/src/components/sidebar/pages/wallpaper_ocs_browser.vala +++ b/src/components/sidebar/pages/wallpaper_ocs_browser.vala @@ -48,6 +48,19 @@ namespace Singularity.Shell { // OCS serves 10 per page unless asked otherwise. 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. A cache + // repaint of the full aggregate builds every one of those synchronously + // in load_cached()/revalidate_cached(); at the current real feed size + // (3358 items, see CRAWL_ITEM_CAP) that is over two seconds of + // unbroken main-thread work -- the whole shell freezes for the + // duration. Measured 2026-09-13 on cixmini: read=1.5ms parse=96.6ms + // add_card-loop=2139.6ms for 3358 cached items, i.e. the card + // construction, not the JSON parse, is the bottleneck. Building the + // same list in bounded batches one main-loop turn apart keeps every + // batch under a perceptible-freeze threshold and lets the grid + // visibly fill 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; @@ -628,7 +641,7 @@ namespace Singularity.Shell { // one cache entry per provider serves every filter combination -- // changing a filter is a different VIEW, never a different crawl. if (!force_refresh) { - var cache_result = load_cached(provider, gen, cancel); + var cache_result = yield load_cached(provider, gen, cancel); if (cache_result == CacheLoadResult.FRESH) return; if (cache_result == CacheLoadResult.STALE) { revalidate_cached.begin(selected_provider, provider, todo, gen, cancel); @@ -706,7 +719,7 @@ namespace Singularity.Shell { // Repaint from any valid on-disk snapshot. Freshness controls whether // browse_all() is finished or starts a silent background revalidate; // it never controls whether already persisted metadata can be shown. - private CacheLoadResult load_cached(string provider, int gen, Cancellable cancel) { + private async CacheLoadResult load_cached(string provider, int gen, Cancellable cancel) { int64 at = WallpaperBrowseCache.now(); WallpaperBrowseCache? cached = null; string path = WallpaperBrowseCache.path_for(provider); @@ -720,10 +733,11 @@ namespace Singularity.Shell { } } if (cached == null || cached.entries.size == 0) return CacheLoadResult.NONE; - foreach (var entry in cached.entries) { - bool card_new_tag; - add_card(entry.item, entry.category, out card_new_tag); - } + 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; rebuild_tag_row(); filter_cards(); @@ -737,6 +751,34 @@ namespace Singularity.Shell { 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 aggregate (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; + bool card_new_tag; + add_card(entry.item, entry.category, out card_new_tag); + 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. @@ -777,10 +819,13 @@ namespace Singularity.Shell { grid.remove_all(); item_category.clear(); known_tag_ids.clear(); - foreach (var entry in state.results) { - bool card_new_tag; - add_card(entry.item, entry.category, out card_new_tag); - } + // 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; rebuild_tag_row(); filter_cards(); if (state.errors.size > 0) From 6b6401899113047758b4db79465ebe9f1ff7175b Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Tue, 15 Sep 2026 16:43:01 -0400 Subject: [PATCH 11/11] feat(wallpaper): simplify OCS browser to category-only, lazy per-category loading Removes tag-based filtering (UI, active/known-tag bookkeeping, the card_matches tag check) and the now-redundant Clear Filters action, leaving category as the only filter dimension. Replaces the eager, load-every-category-and-merge-into-one-aggregate crawl with a lazy, per-category load: selecting a provider now only populates the category dropdown, and nothing is fetched until the user actually picks a category. Re-picking (or re-entering the page on) a category re-runs the same load, reusing the existing cache-then- revalidate-if-stale flow -- now scoped to provider+category instead of provider alone (WallpaperBrowseCache.path_for/save/load gain an optional category argument; path_for(provider) alone is unchanged, so existing single-arg callers and the test suite are unaffected). This was previously pulling 3300+ items across every usable category on every provider select (measured against pling: 45 categories, 3485 results) before the user had picked anything, which is real time lost per open and a UI freeze while the resulting merged grid was built. Verified via the wallpaper-ocs-test target (34/34 pass, unchanged from before this change) and by reading through every call site touched by the add_card()/card_matches()/store_cache() signature simplifications that removing tag bookkeeping and per-item category lookup made possible. singularity-desktop itself currently fails to build for an unrelated, pre-existing reason (FanControlChannel/SensorMonitor.gpu_ sampling missing from libsingularity, confirmed present on a fresh clone of upstream main independent of this branch) -- not touched here, flagged separately on the PR. Assisted-by: Claude Code:claude-opus-5 AI-Scope: implemented the tag-removal and lazy-loading rearchitecture across wallpaper_ocs_browser.vala and wallpaper_browse_cache.vala from the operator's specification (drop tag filtering, load a category only when picked, refresh on re-browse); verification and the PR-state investigation are mine. --- .../sidebar/pages/wallpaper_ocs_browser.vala | 345 +++++++----------- src/core/wallpaper_browse_cache.vala | 58 ++- 2 files changed, 181 insertions(+), 222 deletions(-) diff --git a/src/components/sidebar/pages/wallpaper_ocs_browser.vala b/src/components/sidebar/pages/wallpaper_ocs_browser.vala index 4cd2649..17f7e43 100644 --- a/src/components/sidebar/pages/wallpaper_ocs_browser.vala +++ b/src/components/sidebar/pages/wallpaper_ocs_browser.vala @@ -31,35 +31,27 @@ namespace Singularity.Shell { // 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 provider with many usable - // categories fills the grid quickly, but small enough that a single - // provider cannot fork dozens of OCS processes against the helper at - // once. Empirically a provider exposes on the order of one to a few - // dozen usable categories; 4 leaves headroom on each without spiking - // the helper or the UI thread. + // 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 the total wallpapers merged across every usable - // category for one provider. One page per category at the server's - // maximum 100 items/page (see OcsWallpaperProvider) across pling's - // 45 usable categories measured 2026-09-13 yields 3485 results and - // 3352 unique importable items. Keep the cap above that real total so - // it guards against unexpected provider growth instead of truncating - // today's feed. Do NOT assume the helper's own page default matches: - // OCS serves 10 per page unless asked otherwise. + // 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. A cache - // repaint of the full aggregate builds every one of those synchronously - // in load_cached()/revalidate_cached(); at the current real feed size - // (3358 items, see CRAWL_ITEM_CAP) that is over two seconds of - // unbroken main-thread work -- the whole shell freezes for the - // duration. Measured 2026-09-13 on cixmini: read=1.5ms parse=96.6ms - // add_card-loop=2139.6ms for 3358 cached items, i.e. the card - // construction, not the JSON parse, is the bottleneck. Building the - // same list in bounded batches one main-loop turn apart keeps every - // batch under a perceptible-freeze threshold and lets the grid - // visibly fill in instead of the shell appearing to hang. + // 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. @@ -78,15 +70,14 @@ namespace Singularity.Shell { private ArrayList cards = new ArrayList(); private HashSet thumbnail_requested = new HashSet(); private HashSet thumbnail_pending = new HashSet(); - // Filters use stable provider/tag IDs; display labels are separate. + // 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 HashSet active_tag_ids = new HashSet(); - private HashSet known_tag_ids = new HashSet(); private PreferencesGroup provider_group; - private PreferencesGroup filter_group; private SelectionRow provider_row; private SelectionRow category_row; - private SelectionRow tag_row; private EntryRow? search_row; private Button refresh; private Spinner spinner; @@ -132,11 +123,13 @@ namespace Singularity.Shell { // // That re-browse is cache-aware: a crawl younger than // WallpaperBrowseCache.TTL_SECONDS repaints the grid from disk instead - // of re-crawling every category over the network, which is what makes + // 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. The Refresh - // button is the explicit way to bypass the cache. + // 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) { @@ -198,9 +191,9 @@ namespace Singularity.Shell { }); refresh = new Button.from_icon_name("view-refresh-symbolic"); // force_refresh does two things: it bypasses the on-disk crawl - // cache in browse_all(), 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. + // 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(() => { @@ -217,17 +210,6 @@ namespace Singularity.Shell { category_group.add_row(category_row); add_group(category_group); - filter_group = new PreferencesGroup(_("Filters")); - var clear_filters_button = new Button.with_label(_("Clear Filters")); - clear_filters_button.has_frame = false; - clear_filters_button.valign = Align.CENTER; - clear_filters_button.clicked.connect(clear_filters); - filter_group.add_header_suffix(clear_filters_button); - tag_row = new SelectionRow.with_options(_("Tag"), - new Gee.ArrayList()); - filter_group.add_row(tag_row); - add_group(filter_group); - var results_group = new PreferencesGroup(); var progress_row = new PreferencesRow(); progress_row.activatable = false; @@ -273,9 +255,6 @@ namespace Singularity.Shell { category_row.selected.connect((id) => { if (!updating) on_category_row_selected(id); }); - tag_row.selected.connect((id) => { - if (!updating) on_tag_row_selected(id); - }); initialize.begin(); } @@ -294,9 +273,8 @@ namespace Singularity.Shell { 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 or tag changes nothing visible, so - // the category dropdown disables while loading. The tag dropdown - // stays enabled while loading because it has no tags yet. + // 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; @@ -441,9 +419,6 @@ namespace Singularity.Shell { cards.clear(); reset_thumbnail_loading(); grid.remove_all(); - item_category.clear(); - known_tag_ids.clear(); - rebuild_tag_row(); status.label = _("Searching Stock Photos…"); update_controls(); bool refresh_now = force_refresh; @@ -454,17 +429,13 @@ namespace Singularity.Shell { 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) { - bool tag; - add_card(item, "", out tag); - } + 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; - rebuild_tag_row(); loading = false; filter_cards(); status.label = result_status; @@ -472,11 +443,15 @@ namespace Singularity.Shell { update_controls(); } - // Provider selected -> rebuild the category dropdown, then kick off - // the aggregate crawl for every usable category under that provider. - // For Bing the "categories" are actually markets fetched from a - // different helper; the dropdown is the same widget, but the - // underlying command and parser branch. + // 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; @@ -488,7 +463,6 @@ namespace Singularity.Shell { online_search_group.visible = photos; category_row.visible = !photos; active_category_id = ""; - active_tag_ids.clear(); if (photos) { photo_page = 1; categories.clear(); @@ -538,16 +512,21 @@ namespace Singularity.Shell { rebuild_provider_row(provider.id); categories = loaded; active_category_id = ""; - active_tag_ids.clear(); - known_tag_ids.clear(); rebuild_category_row(); - rebuild_tag_row(); cards.clear(); reset_thumbnail_loading(); grid.remove_all(); loading = false; update_controls(); - browse_all.begin(); + 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; @@ -558,14 +537,13 @@ namespace Singularity.Shell { private void rebuild_category_row() { var options = new Gee.ArrayList(); - options.add(new Singularity.Core.AppSettingOption() { id = "", label = _("Any category") }); + 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 filter with one option filters nothing. Bing's de-duplicated - // combined view is a single choice by construction, and an "Any - // category / Combined (All Markets)" dropdown next to a grid that - // is already exactly that is just noise. + // 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; @@ -573,41 +551,57 @@ namespace Singularity.Shell { updating = was_updating; } - private void clear_filters() { - updating = true; - active_category_id = ""; - active_tag_ids.clear(); - rebuild_category_row(); - rebuild_tag_row(); - if (search_row != null) search_row.text = ""; - updating = false; - filter_cards(); - } - private void on_category_row_selected(string id) { active_category_id = id; - filter_cards(); - } - - private void on_tag_row_selected(string id) { - active_tag_ids.clear(); - if (id != "") active_tag_ids.add(id); - filter_cards(); + 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); } - // The aggregate crawl. Pulls a single page from every usable category - // for the selected provider, in parallel bounded by CRAWL_WORKERS, - // then merges results into the grid. Live progress ("Loaded K of N - // categories · M wallpapers so far") is reported through `status` - // each time a category finishes. The Cancellable cuts the rest of - // the crawl off cleanly when the user starts a - // fresh crawl. + // 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(); @@ -619,47 +613,30 @@ namespace Singularity.Shell { return; } string provider = provider_row.current_value; - // Snapshot the category list under the current generation so a - // provider change mid-crawl cannot mutate the work queue. + var selected_provider = provider_registry.lookup(provider); var todo = new ArrayList(); - foreach (var c in categories) todo.add(c.id); - int total = todo.size; + todo.add(category); + int total = 1; cards.clear(); reset_thumbnail_loading(); grid.remove_all(); - // The category side-map is keyed by item, not by provider, so it - // has to be dropped with the cards it described -- otherwise a - // cache load or a provider switch repopulates cards while stale - // entries from the previous crawl still answer card_matches(). - item_category.clear(); - known_tag_ids.clear(); - rebuild_tag_row(); + 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. Every filter (category, tag, - // free text) is applied client-side to this same merged list, so - // one cache entry per provider serves every filter combination -- - // changing a filter is a different VIEW, never a different crawl. + // 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, gen, cancel); + 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, todo, gen, cancel); + revalidate_cached.begin(selected_provider, provider, category, todo, gen, cancel); return; } } - if (total == 0) { - loading = false; - status.label = _("No usable wallpaper categories for this provider."); - update_controls(); - return; - } loading = true; - // Bing's combined view is a one-entry crawl, so the plural form - // would read "Loading 1 categories…" for every user who has all - // markets enabled. - status.label = total == 1 ? _("Loading 1 category…") - : _("Loading %d categories…").printf(total); + 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. @@ -670,9 +647,9 @@ namespace Singularity.Shell { state.todo = todo; state.total = total; state.cancel = cancel; - // Pool of workers. Each worker pulls one id off the shared - // queue at a time until it is drained; an outer fan-out starts - // up to CRAWL_WORKERS at once. + // 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); @@ -688,7 +665,7 @@ namespace Singularity.Shell { // thumbnails; closing/restarting still cancels both. break; } - SourceFunc resume = browse_all.callback; + SourceFunc resume = browse_category.callback; Timeout.add(100, () => { if (resume != null) { SourceFunc cb = (owned) resume; @@ -712,17 +689,18 @@ namespace Singularity.Shell { // 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, state.errors.size > 0); + store_cache(provider, category, state.errors.size > 0); queue_viewport_thumbnails(); } // Repaint from any valid on-disk snapshot. Freshness controls whether - // browse_all() 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, int gen, Cancellable cancel) { + // 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); + string path = WallpaperBrowseCache.path_for(provider, category); if (FileUtils.test(path, FileTest.IS_REGULAR)) { try { string data; @@ -739,7 +717,6 @@ namespace Singularity.Shell { // touching status/controls here would fight it. if (gen != generation || cancel.is_cancelled()) return CacheLoadResult.FRESH; loading = false; - rebuild_tag_row(); filter_cards(); // filter_cards() has just written the shown/loaded counts; append // the provenance so a cached grid never silently poses as a fresh @@ -752,7 +729,7 @@ namespace Singularity.Shell { } // Build one WallpaperCard per entry in bounded batches, yielding to - // the main loop between batches so a large aggregate (see + // 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 @@ -762,8 +739,7 @@ namespace Singularity.Shell { int processed = 0; foreach (var entry in entries) { if (gen != generation || cancel.is_cancelled()) return; - bool card_new_tag; - add_card(entry.item, entry.category, out card_new_tag); + add_card(entry.item); if (++processed % CARD_BUILD_BATCH_SIZE != 0) continue; queue_viewport_thumbnails(); SourceFunc resume = populate_cards_batched.callback; @@ -782,7 +758,7 @@ namespace Singularity.Shell { // 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, + 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(); @@ -809,16 +785,14 @@ namespace Singularity.Shell { } if (gen != generation || cancel.is_cancelled()) return; if (state.results.size == 0) { - warning("Background wallpaper cache revalidation for %s produced no usable results", provider); + warning("Background wallpaper cache revalidation for %s/%s produced no usable results", provider, category); return; } - WallpaperBrowseCache.save(provider, state.results, + WallpaperBrowseCache.save(provider, category, state.results, state.errors.size > 0, WallpaperBrowseCache.now()); cards.clear(); reset_thumbnail_loading(); grid.remove_all(); - item_category.clear(); - known_tag_ids.clear(); // 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 @@ -826,7 +800,6 @@ namespace Singularity.Shell { // batches spread across further main-loop turns. yield populate_cards_batched(state.results, gen, cancel); if (gen != generation || cancel.is_cancelled()) return; - rebuild_tag_row(); filter_cards(); if (state.errors.size > 0) status.label = _("%d wallpapers loaded · %s").printf( @@ -835,12 +808,11 @@ namespace Singularity.Shell { queue_viewport_thumbnails(); } - private void store_cache(string provider, bool partial) { + 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, - item_category.has_key(card.item.key) ? item_category[card.item.key] : "")); - WallpaperBrowseCache.save(provider, snapshot, partial, WallpaperBrowseCache.now()); + snapshot.add(new WallpaperBrowseCacheEntry(card.item, category)); + WallpaperBrowseCache.save(provider, category, snapshot, partial, WallpaperBrowseCache.now()); } private static string cache_age(int64 seconds) { @@ -906,14 +878,13 @@ namespace Singularity.Shell { try { d = ++state.done_count; } finally { state.count_lock.unlock(); } Idle.add(() => { if (!state.background && state.generation == generation) - status.label = _("Loaded %d/%d categories · %d wallpapers (cap reached)").printf(d, state.total, state.item_count); + 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; - bool any_new_tag = false; try { var result = yield state.backend.browse(category, "", 1, force_refresh, state.cancel); if (state.generation != generation || state.cancel.is_cancelled()) return; @@ -944,9 +915,7 @@ namespace Singularity.Shell { if (WallpaperOcs.provider_id(item.provider_id)) state.seen_ocs_ids.add(item.id); state.results.add(new WallpaperBrowseCacheEntry(item, category)); } else { - bool card_new_tag; - add_card(item, category, out card_new_tag); - any_new_tag = any_new_tag || card_new_tag; + add_card(item); } } } catch (Error e) { @@ -958,21 +927,20 @@ namespace Singularity.Shell { 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 + tag chip updates live on the main thread. The - // captured `d`/`snap`/`category`/`error`/`any_new_tag` are - // local-scope value captures -- safe to use in the Idle - // callback that fires after this async function yields. + // 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 categories · %d wallpapers · %s failed: %s").printf(d, state.total, snap, category, error); + status.label = _("Loaded %d/%d · %d wallpapers · %s failed: %s").printf(d, state.total, snap, category, error); } else { - status.label = _("Loaded %d/%d categories · %d wallpapers so far").printf(d, state.total, snap); + status.label = _("Loaded %d/%d · %d wallpapers so far").printf(d, state.total, snap); } - if (any_new_tag) rebuild_tag_row(); return Source.REMOVE; }); } @@ -989,33 +957,17 @@ namespace Singularity.Shell { row.current_value = current; } - private void rebuild_tag_row() { - var sorted = new ArrayList(); - foreach (var id in known_tag_ids) sorted.add(id); - sorted.sort((a, b) => a.collate(b)); - var options = new Gee.ArrayList(); - options.add(new Singularity.Core.AppSettingOption() { id = "", label = _("Any tag") }); - foreach (var id in sorted) { - options.add(new Singularity.Core.AppSettingOption() { id = id, label = id }); - } - string current_id = active_tag_ids.size > 0 ? active_tag_ids.to_array()[0] : ""; - bool was_updating = updating; - updating = true; - set_choices(tag_row, options, current_id); - updating = was_updating; - } - 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, active_category_id, active_tag_ids); + 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 provider."); + 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); } @@ -1027,25 +979,11 @@ namespace Singularity.Shell { cards[index].card == child.child && cards[index].matches; } - // AND across all three filter axes: text contains-match against - // name + author, the card's provider-mapped category equals the - // active category (or no active filter), and the card has every - // active tag. The card's own category lives on the item; we look - // it up via the items' positions in the per-category crawl by - // stashing it on the item at add_card time. (Provider+id is the - // existing key; we add a `category_id` side-channel via a - // separate map so the model stays clean.) - // Stash the source category alongside each card so the chip-row - // category filter can match it. Gee.HashMap has no try_get; the - // contains-then-index idiom is the standard substitute. - private HashMap item_category = new HashMap(); - private bool card_matches(WallpaperItem item, string query, string active_category, HashSet active_tags) { - if (active_category != "") { - if (!item_category.has_key(item.key)) return false; - string cat = item_category[item.key]; - if (cat == null || cat != active_category) return false; - } - foreach (var t in active_tags) if (!(t in item.tags)) return false; + // 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; @@ -1069,16 +1007,13 @@ namespace Singularity.Shell { return false; } - private void add_card(WallpaperItem item, string source_category, out bool new_tag_added) { - new_tag_added = 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); } - item_category.set(item.key, source_category); - foreach (var t in item.tags) if (known_tag_ids.add(t)) new_tag_added = true; var card = new OcsCard(); card.item = item; // Use placeholder_only: the OCS browser drives its own async diff --git a/src/core/wallpaper_browse_cache.vala b/src/core/wallpaper_browse_cache.vala index e3fb198..8e5ccb0 100644 --- a/src/core/wallpaper_browse_cache.vala +++ b/src/core/wallpaper_browse_cache.vala @@ -16,16 +16,22 @@ namespace Singularity { } } - // On-disk cache of an aggregate browse crawl, one file per provider. + // On-disk cache of one category's browse crawl, one file per + // provider+category. // - // The browser crawls every usable category of a provider and merges the - // results into one deduplicated list, then filters that list in the UI by - // category, tag and free text. Because every filter is applied client-side - // to the same merged list, the cache key is the PROVIDER alone: changing a - // filter selects a different view of the same cached crawl, never a - // different crawl. Providers that search server-side (Openverse, Unsplash) - // are query- and page-addressed, are not part of the aggregate crawl, and - // already have their own helper-side caching, so they are not cached here. + // 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 @@ -64,6 +70,19 @@ namespace Singularity { 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 @@ -73,8 +92,13 @@ namespace Singularity { return Path.build_filename(Environment.get_user_cache_dir(), "singularity", "wallpaper-browse"); } - public static string path_for(string provider) { - return Path.build_filename(directory(), provider + ".json"); + // `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) { @@ -231,15 +255,15 @@ namespace Singularity { } } - public static WallpaperBrowseCache? load(string provider, int64 at) { - if (!valid_provider(provider)) return null; - return read(path_for(provider), provider, at); + 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, Gee.List entries, + public static bool save(string provider, string category, Gee.List entries, bool partial, int64 created) { - if (!valid_provider(provider)) return false; - return write(path_for(provider), provider, entries, partial, created); + if (!valid_provider(provider) || !valid_category(category)) return false; + return write(path_for(provider, category), provider, entries, partial, created); } } }