From 09d5ecea6e70bacaa4043f9c76ab6d977048d27c Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 19 Aug 2026 22:53:35 -0400 Subject: [PATCH 1/2] desktop_page(wallpaper): scan the pack registry, not just backgrounds paths The picker enumerated a single level of a fixed backgrounds-path list and kept only entries whose content-type began with image/. Two bugs in that: - /usr/share/backgrounds contains no images directly, only ncz/ and singularity/ subdirectories -- a directory's content-type is inode/directory, so every shipped wallpaper was silently skipped and had never displayed in the picker. - Packs that declare their own directory outside any backgrounds path are unreachable by walking backgrounds at all. The Bing provider caches into /var/cache/ncz-wallpapers/bing, which nothing in the old scan roots would ever reach. Now reads every installed pack's .collection file for its Dir= entry (the current on-disk registry format; docs/WALLPAPER-PACKS.md's move to .pack.json should parse both when it lands, not replace this, or packs from the older deb disappear from the picker on upgrade) and walks each scan root recursively up to a bounded depth, skipping symlinks (both to avoid directory cycles and to avoid double-listing default.jpg, which the rotator repoints at whichever wallpaper is current) and avoiding re-walking overlapping roots. --- .../sidebar/pages/desktop_page.vala | 138 +++++++++++++++--- 1 file changed, 119 insertions(+), 19 deletions(-) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index d01dfad..42bb86c 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -1790,6 +1790,115 @@ namespace Singularity { }); } + // How deep to walk below a scan root. /usr/share/backgrounds holds + // ncz/, and a pack sits one further down (ncz/brandon-perlow), so two + // levels is what the shipped layout needs. The bound exists because + // $XDG_DATA_HOME/backgrounds is user-writable: someone who points it at + // a deep tree should not stall the picker. + private const int WALLPAPER_SCAN_MAX_DEPTH = 3; + + // Directories declared by installed wallpaper packs. + // + // Reading the registry rather than guessing paths is what surfaces the + // Bing provider at all: its Dir= is /var/cache/ncz-wallpapers/bing, + // which is not under any backgrounds path and is unreachable by + // directory walking alone. + // + // .collection is the current on-disk format (KeyFile). The design in + // docs/WALLPAPER-PACKS.md moves to .pack.json and accepts both for one + // release; when that lands, parse *.pack.json here too rather than + // replacing this, or packs installed by the older deb disappear from + // the picker on upgrade. + private static Gee.ArrayList collection_dirs() { + var dirs = new ArrayList(); + var roots = new ArrayList(); + foreach (unowned string d in GLib.Environment.get_system_data_dirs()) + roots.add(GLib.Path.build_filename(d, "ncz-wallpapers", "collections")); + roots.add(GLib.Path.build_filename(GLib.Environment.get_user_data_dir(), + "ncz-wallpapers", "collections")); + + foreach (string root in roots) { + try { + var dir = File.new_for_path(root); + if (!dir.query_exists()) continue; + var en = dir.enumerate_children("standard::name", FileQueryInfoFlags.NONE, null); + FileInfo info; + while ((info = en.next_file(null)) != null) { + if (!info.get_name().has_suffix(".collection")) continue; + var kf = new GLib.KeyFile(); + try { + kf.load_from_file(GLib.Path.build_filename(root, info.get_name()), + GLib.KeyFileFlags.NONE); + string d = kf.get_string("Collection", "Dir"); + if (d != null && d != "" && !dirs.contains(d)) dirs.add(d); + } catch (Error e) { + // A malformed or Dir-less collection is skipped, not + // fatal: one bad pack must not empty the picker. + } + } + } catch (Error e) { + } + } + return dirs; + } + + // Walk one scan root, collecting images. + // + // The previous implementation enumerated a single level and kept only + // entries whose content-type began with image/. /usr/share/backgrounds + // contains no images at all -- only ncz/ and singularity/ -- and a + // directory's content-type is inode/directory, so every shipped + // wallpaper was silently skipped. The picker had never displayed them. + private static void scan_wallpaper_dir(string path, + ArrayList candidates, + HashSet thread_seen, + HashSet visited_dirs, + int depth) { + if (depth > WALLPAPER_SCAN_MAX_DEPTH) return; + // The scan roots overlap by construction (/usr/share/backgrounds and + // /usr/share/backgrounds/singularity are both roots) and a pack may + // declare a Dir already reachable from one of them. Without this, + // those directories are walked more than once. + if (visited_dirs.contains(path)) return; + visited_dirs.add(path); + + try { + var dir = File.new_for_path(path); + if (!dir.query_exists()) return; + var enumerator = dir.enumerate_children( + "standard::name,standard::content-type,standard::type,standard::is-symlink", + FileQueryInfoFlags.NONE, null); + FileInfo info; + while ((info = enumerator.next_file(null)) != null) { + var child = dir.get_child(info.get_name()); + + if (info.get_file_type() == FileType.DIRECTORY) { + // Not followed as a directory either: a symlinked + // directory is the easy way to walk in a circle. + if (info.get_is_symlink()) continue; + scan_wallpaper_dir(child.get_path(), candidates, thread_seen, + visited_dirs, depth + 1); + continue; + } + + // default.jpg is a symlink the rotator repoints at whichever + // wallpaper is current. Its target is enumerated in the same + // directory, so following it would list one image twice -- + // once under its own name and once as "default". + if (info.get_is_symlink()) continue; + + string mime = info.get_content_type(); + if (mime == null || !mime.has_prefix("image/")) continue; + + string uri = child.get_uri(); + if (thread_seen.contains(uri)) continue; + thread_seen.add(uri); + candidates.add(new WallpaperCandidate(uri, false)); + } + } catch (Error e) { + } + } + private void populate_grid() { int gen = ++wallpaper_grid_generation; wallpaper_grid.remove_all(); @@ -1813,31 +1922,22 @@ namespace Singularity { path_list.add(GLib.Path.build_filename(d, "backgrounds")); path_list.add(GLib.Path.build_filename(GLib.Environment.get_user_data_dir(), "backgrounds")); + // Packs declare their own directory, and it need not live under any + // backgrounds path. The Bing provider caches into + // /var/cache/ncz-wallpapers/bing, which nothing above would ever + // reach, so the registry is the only way those images are found. + foreach (string dir in collection_dirs()) + path_list.add(dir); + string[] scan_paths = path_list.to_array(); new GLib.Thread("wallpaper-scan", () => { var candidates = new ArrayList(); var thread_seen = new HashSet(); foreach (string uri in seen) thread_seen.add(uri); - foreach (string path in scan_paths) { - try { - var dir = File.new_for_path(path); - if (!dir.query_exists()) continue; - var enumerator = dir.enumerate_children("standard::name,standard::content-type", FileQueryInfoFlags.NONE, null); - FileInfo info; - while ((info = enumerator.next_file(null)) != null) { - string mime = info.get_content_type(); - if (mime.has_prefix("image/")) { - string uri = dir.get_child(info.get_name()).get_uri(); - if (!thread_seen.contains(uri)) { - thread_seen.add(uri); - candidates.add(new WallpaperCandidate(uri, false)); - } - } - } - } catch (Error e) { - } - } + var visited_dirs = new HashSet(); + foreach (string path in scan_paths) + scan_wallpaper_dir(path, candidates, thread_seen, visited_dirs, 0); GLib.Idle.add(() => { if (gen != wallpaper_grid_generation) return GLib.Source.REMOVE; From 0adbc991ef23a35476a3c7919926671a380341e7 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Fri, 4 Sep 2026 19:15:04 -0400 Subject: [PATCH 2/2] desktop_page(wallpaper): follow cross-directory symlinks, only elide same-dir pointers Codex review on PR #24 caught a regression: the previous commit skipped every symlinked file to avoid double-listing default.jpg, but that also drops a pack's wallpaper if it ships one as a symlink to a shared asset outside the scanned directory -- the old scanner followed those fine (content-type resolves through the link). Only elide a symlink whose target sits in the same directory being scanned, which is the actual double-listing case. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018Hn3H7xGPDu6W2ntjotqao --- .../sidebar/pages/desktop_page.vala | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index 42bb86c..ac91141 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -1866,7 +1866,7 @@ namespace Singularity { var dir = File.new_for_path(path); if (!dir.query_exists()) return; var enumerator = dir.enumerate_children( - "standard::name,standard::content-type,standard::type,standard::is-symlink", + "standard::name,standard::content-type,standard::type,standard::is-symlink,standard::symlink-target", FileQueryInfoFlags.NONE, null); FileInfo info; while ((info = enumerator.next_file(null)) != null) { @@ -1882,10 +1882,23 @@ namespace Singularity { } // default.jpg is a symlink the rotator repoints at whichever - // wallpaper is current. Its target is enumerated in the same - // directory, so following it would list one image twice -- - // once under its own name and once as "default". - if (info.get_is_symlink()) continue; + // wallpaper is current, at a target enumerated in this same + // directory -- following it would list one image twice, once + // under its own name and once as "default". Only elide a + // same-directory pointer like that one: a pack that ships an + // image as a symlink to a shared asset OUTSIDE this directory + // is real content, and the previous scanner listed it fine + // (content-type resolves through the link either way, since + // enumerate_children above passes no NOFOLLOW flag). + if (info.get_is_symlink()) { + string? target = info.get_symlink_target(); + if (target != null) { + string resolved = Path.is_absolute(target) + ? target + : Path.build_filename(path, target); + if (Path.get_dirname(resolved) == path) continue; + } + } string mime = info.get_content_type(); if (mime == null || !mime.has_prefix("image/")) continue;