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