Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ singularity_core_sources = files(
'src/core/preview_cache.vala',
'src/core/extreme_mode_manager.vala',
'src/core/wallpaper_manager.vala',
'src/core/wallpaper_collections.vala',
'src/core/wallpaper_gallery.vala',
'src/core/wallpaper_rotation_state.vala',
'src/core/wallpaper_rotator.vala',
'src/core/wayland_gamma_backend.vala',
'src/core/shortcut_manager.vala',
'src/core/ush_portal.vala',
Expand Down Expand Up @@ -442,3 +446,29 @@ safe_mode_test = executable('safe-mode-test',
dependencies: [dependency('gobject-2.0')],
)
test('safe-mode', safe_mode_test)

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],
)
test('wallpaper-collections', wallpaper_collections_test)

wallpaper_rotation_state_test = executable('wallpaper-rotation-state-test',
sources: ['src/core/wallpaper_rotation_state.vala', 'tests/wallpaper_rotation_state_test.vala'],
dependencies: [dependency('gobject-2.0'), dependency('glib-2.0'), dependency('gio-2.0')],
)
test('wallpaper-rotation-state', wallpaper_rotation_state_test)

wallpaper_gallery_test = executable('wallpaper-gallery-test',
sources: ['src/core/wallpaper_gallery.vala', 'tests/wallpaper_gallery_test.vala'],
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],
)
test('wallpaper-rotator', wallpaper_rotator_test)
279 changes: 115 additions & 164 deletions src/components/sidebar/pages/desktop_page.vala

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/core/main.vala
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ public class SingularityApp : Singularity.ShellApplication, Singularity.Shell.Sh
// connection is established at login, not on the first search.
Singularity.SearchManager.get_default();
Singularity.NowPlayingCache.get_default();
// Do not change persisted wallpaper state during safe-mode recovery.
if (Singularity.SafeMode.get_default().allows(
Singularity.SafeFeature.AUTOSTART))
Singularity.WallpaperManager.get_default().start_rotation();
// Once the startup allocation storm (GL shader compile, icon and
// theme loading) has settled, hand the freed pages back to the OS.
GLib.Timeout.add_seconds(10, () => {
Expand Down
97 changes: 97 additions & 0 deletions src/core/wallpaper_collections.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using GLib;
using Gee;

namespace Singularity {

public class WallpaperCollectionInfo : Object {
// Vala rejects a GObject property named "type"; keep these as fields.
public string id;
public string name;
public string artist;
public string dir;
public string type;

public WallpaperCollectionInfo(string id, string name, string artist, string dir, string type) {
this.id = id;
this.name = name;
this.artist = artist;
this.dir = dir;
this.type = type;
}
}

public class WallpaperCollections : Object {
// parse() is first-root-wins, so system collections take precedence.
public static string[] default_search_roots() {
string[] roots = {};
foreach (unowned string d in GLib.Environment.get_system_data_dirs())
roots += GLib.Path.build_filename(d, "singularity", "wallpaper-collections");
roots += GLib.Path.build_filename(
GLib.Environment.get_user_data_dir(), "singularity", "wallpaper-collections");
return roots;
}

public static Gee.ArrayList<WallpaperCollectionInfo> parse(string[] search_roots) {
var results = new Gee.ArrayList<WallpaperCollectionInfo>();
var seen_ids = new Gee.HashSet<string>();

foreach (string root in search_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) {
string filename = info.get_name();
if (!filename.has_suffix(".collection")) continue;

var kf = new GLib.KeyFile();
try {
kf.load_from_file(GLib.Path.build_filename(root, filename), GLib.KeyFileFlags.NONE);
} catch (Error e) {
continue; // malformed file, skip it
}

string collection_dir;
try {
collection_dir = kf.get_string("Collection", "Dir").strip();
} catch (Error e) {
continue; // Dir-less collection, skip it
}
if (collection_dir == "") continue;

string id;
try {
id = kf.get_string("Collection", "Id").strip();
} catch (Error e) {
id = "";
}
if (id == "") {
id = filename.substring(0, filename.length - ".collection".length);
}
if (!seen_ids.add(id)) continue; // first root wins

string name;
try { name = kf.get_string("Collection", "Name").strip(); }
catch (Error e) { name = ""; }
if (name == "") name = id;

string artist;
try { artist = kf.get_string("Collection", "Artist").strip(); }
catch (Error e) { artist = ""; }

string type;
try { type = kf.get_string("Collection", "Type").strip(); }
catch (Error e) { type = ""; }
if (type == "") type = "static";

results.add(new WallpaperCollectionInfo(id, name, artist, collection_dir, type));
}
} catch (Error e) {
continue;
}
}
return results;
}
}
}
105 changes: 105 additions & 0 deletions src/core/wallpaper_gallery.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using GLib;
using Gee;

namespace Singularity {
internal class WallpaperCandidate : Object {
public string uri { get; private set; }
public bool is_recent { get; private set; }

public WallpaperCandidate(string uri, bool is_recent) {
this.uri = uri;
this.is_recent = is_recent;
}
}

internal class WallpaperGallery : Object {
// History may reorder results, but cannot add another source's images.
public static ArrayList<WallpaperCandidate> scan(string? selected_dir,
string[] collection_dirs,
string[] recent) {
var scanned = new ArrayList<WallpaperCandidate>();
var members = new HashSet<string>();
var excluded = new HashSet<string>();
var result = new ArrayList<WallpaperCandidate>();
if (selected_dir == null) return result;
string root = canonical_path(selected_dir);
foreach (string dir in collection_dirs) {
string other = canonical_path(dir);
if (other != root) excluded.add(other);
}
scan_wallpaper_dir(root, scanned, members, new HashSet<string>(), excluded, 0);
var added = new HashSet<string>();
foreach (string uri in recent) {
if (members.contains(uri) && added.add(uri))
result.add(new WallpaperCandidate(uri, true));
}
foreach (var candidate in scanned) {
if (added.add(candidate.uri)) result.add(candidate);
}
return result;
}

// Bound traversal of user-controlled collection directories.
private const int WALLPAPER_SCAN_MAX_DEPTH = 3;

// Resolve symlinks before enforcing collection boundaries.
private static string canonical_path(string path) {
string? real = Posix.realpath(path, null);
return real ?? File.new_for_path(path).get_path();
}

// Other registered roots are separate sources, even when nested.
private static void scan_wallpaper_dir(string path,
ArrayList<WallpaperCandidate> candidates,
HashSet<string> thread_seen,
HashSet<string> visited_dirs,
HashSet<string> excluded_dirs,
int depth) {
if (depth > WALLPAPER_SCAN_MAX_DEPTH || excluded_dirs.contains(path)) return;
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,standard::symlink-target",
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) {
// Do not follow symlinked directories; they may form cycles.
if (info.get_is_symlink()) continue;
scan_wallpaper_dir(child.get_path(), candidates, thread_seen,
visited_dirs, excluded_dirs, depth + 1);
continue;
}

// Ignore same-directory aliases such as the mutable default.jpg,
// but retain symlinks to shared assets outside the collection.
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;

string uri = child.get_uri();
if (thread_seen.contains(uri)) continue;
thread_seen.add(uri);
candidates.add(new WallpaperCandidate(uri, false));
}
} catch (Error e) {
}
}

}
}
15 changes: 15 additions & 0 deletions src/core/wallpaper_manager.vala
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace Singularity {
private string? _cached_path = null;
private int _load_serial = 0;
private Mutex _mutex = Mutex ();
private WallpaperRotator? rotator = null;

public signal void wallpaper_changed();

Expand All @@ -33,6 +34,20 @@ namespace Singularity {
reload();
}

public void start_rotation() {
if (rotator != null) return;
rotator = WallpaperRotator.get_default();
rotator.current_uri = settings.get_string("background-picture-uri");
rotator.wallpaper_selected.connect((uri) => {
settings.set_string("background-picture-uri", uri);
});
// Track external changes so rotation does not reselect the current image.
settings.changed["background-picture-uri"].connect(() => {
rotator.current_uri = settings.get_string("background-picture-uri");
});
rotator.start();
}

public void reload() {
string custom_uri = settings.get_string("background-picture-uri");
string? path = resolve_path(custom_uri);
Expand Down
86 changes: 86 additions & 0 deletions src/core/wallpaper_rotation_state.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using GLib;

namespace Singularity {

public class WallpaperRotationState : Object {
private const int DEFAULT_INTERVAL_SECONDS = 600;
private const int MIN_INTERVAL_SECONDS = 30;

private string config_dir;

public WallpaperRotationState(string config_dir) {
this.config_dir = config_dir;
}

public static string default_config_dir() {
return GLib.Path.build_filename(
GLib.Environment.get_user_config_dir(), "singularity", "wallpaper-rotation");
}

private string path_for(string filename) {
return GLib.Path.build_filename(config_dir, filename);
}

private string? read_trimmed(string filename) {
string path = path_for(filename);
if (!FileUtils.test(path, FileTest.EXISTS)) return null;
string contents;
try {
FileUtils.get_contents(path, out contents);
} catch (Error e) {
return null;
}
return contents.strip();
}

private void write(string filename, string contents) {
GLib.DirUtils.create_with_parents(config_dir, 0700);
string dest = path_for(filename);
string tmp = dest + ".tmp";
try {
// Same-directory rename keeps state updates atomic for readers.
FileUtils.set_contents(tmp, contents);
if (FileUtils.rename(tmp, dest) != 0) {
warning("wallpaper rotation state: could not rename %s into place", filename);
}
} catch (Error e) {
warning("wallpaper rotation state: could not write %s: %s", filename, e.message);
}
}

public string get_selected_collection(string default_id) {
string? value = read_trimmed("collection");
return (value == null || value == "") ? default_id : value;
}

public void set_selected_collection(string id) {
write("collection", id);
}

// Rotation is opt-in; an absent state file must remain off.
public bool get_rotate_enabled() {
string? value = read_trimmed("rotate-enabled");
if (value == null) return false;
string lowered = value.down();
return lowered != "0" && lowered != "false" && lowered != "off";
}

public void set_rotate_enabled(bool enabled) {
write("rotate-enabled", enabled ? "1" : "0");
}

public int get_rotate_interval_seconds() {
string? value = read_trimmed("rotate-interval");
if (value == null) return DEFAULT_INTERVAL_SECONDS;
int64 parsed;
if (!int64.try_parse(value, out parsed)) return DEFAULT_INTERVAL_SECONDS;
int seconds = (int) parsed;
return seconds < MIN_INTERVAL_SECONDS ? MIN_INTERVAL_SECONDS : seconds;
}

public void set_rotate_interval_seconds(int seconds) {
int clamped = seconds < MIN_INTERVAL_SECONDS ? MIN_INTERVAL_SECONDS : seconds;
write("rotate-interval", clamped.to_string());
}
}
}
Loading