Files

585 lines
23 KiB
QML

pragma Singleton
// Verified wallpaper policy application. hyprpaper 0.8 applies one output per
// IPC call, so Panama queues every connected output and persists a policy only
// after listactive confirms the complete map.
import Quickshell
import Quickshell.Io
import QtQuick
import "WallpaperPolicy.js" as WallpaperPolicy
import qs.config
Singleton {
id: root
property var available: []
property var activeByOutput: ({})
property string lastError: ""
property bool scanning: false
property var transaction: null
property bool startupRestoreEnabled: true
property var outputOverride: null
property var candidateOverride: null
property string slideshowPath: ""
property int slideshowIndex: -1
property var shuffleBag: []
property int slideshowIntervalOverrideMs: 0
property bool pendingHotplugReapply: false
readonly property string configured: DesktopPreferences.get("wallpaperPath")
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
readonly property string active: {
const outputs = root.outputNames();
if (outputs.length > 0 && root.activeByOutput[outputs[0]])
return root.activeByOutput[outputs[0]];
const paths = Object.values(root.activeByOutput);
return paths.length > 0 ? paths[0] : "";
}
readonly property bool busy: root.transaction !== null
|| applyProcess.running || verifyProcess.running
readonly property var slideshowCollection: WallpaperPolicy.validCollection(
DesktopPreferences.get("wallpaperSlideshowPaths") ?? [], root.candidates([]))
readonly property bool slideshowTimerRunning: slideshowTimer.running
readonly property string outputSignature: root.outputNames().slice().sort().join("|")
property var outputNames: function() {
const outputs = Array.isArray(root.outputOverride)
? root.outputOverride.slice()
: Quickshell.screens.map(screen => screen.name).filter(name => !!name);
return root.primaryFirstOutputs(outputs);
}
// One folder, chosen in Settings, rather than four swept blindly. A leading
// slash means an absolute path -- a drive that is not under home -- and
// anything else is read relative to it, which is how the capture folders
// work too.
readonly property var searchRoots: {
const configured = String(DesktopPreferences.get("wallpaperDir") ?? "").trim();
const folder = configured === "" ? "Pictures/Wallpapers" : configured;
const home = Quickshell.env("HOME");
if (folder.startsWith("/"))
return [folder];
if (folder.startsWith("~/"))
return [home + folder.slice(1)];
return [`${home}/${folder}`];
}
Process {
id: scan
command: [Quickshell.shellDir + "/scripts/panama-wallpaper-scan"]
.concat(root.searchRoots)
stdout: StdioCollector {
onStreamFinished: {
root.available = this.text.split("\n")
.map(line => line.trim()).filter(line => line.length > 0);
root.scanning = false;
}
}
}
Process {
id: activeQuery
command: ["hyprctl", "hyprpaper", "listactive"]
stdout: StdioCollector {
onStreamFinished: {
const parsed = root.parseActive(this.text);
if (parsed !== null)
root.activeByOutput = parsed;
}
}
}
Process {
id: applyProcess
onExited: (exitCode, exitStatus) => {
if (root.transaction === null)
return;
if (exitCode !== 0) {
root.transaction = null;
// Coming back from a video, hyprpaper's service is being
// started again underneath us and may not have its socket yet.
// That is a "not ready", not a "no", so the handoff is retried
// a few times before it counts as a failure.
if (root.pendingStillPolicy !== null
&& root.stillHandoffAttempts < root.stillHandoffLimit) {
stillAfterVideo.restart();
return;
}
root.lastError = "Hyprpaper did not apply that background.";
root.abandonStillHandoff();
root.schedulePendingHotplug();
return;
}
root.drainTransaction();
}
}
Process {
id: verifyProcess
property string outputText: ""
onStarted: outputText = ""
stdout: StdioCollector {
onStreamFinished: verifyProcess.outputText = this.text
}
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
}
// `busy` is the gate on every apply path, on refreshActive and on the
// slideshow timer, and a transaction is the only thing that clears it. So a
// hyprctl that never returns does not fail a wallpaper change — it takes the
// whole wallpaper surface out of service for the rest of the session, with
// no error to say why. Nothing here is a ten-second operation.
Timer {
id: transactionWatchdog
interval: 10000
onTriggered: {
if (root.transaction === null)
return;
root.transaction = null;
root.abandonStillHandoff();
// Killing the stuck child is part of the release: `busy` counts the
// processes too, so leaving one running would keep the latch shut.
applyProcess.running = false;
verifyProcess.running = false;
root.lastError = "Hyprpaper stopped responding while setting that background.";
}
}
onTransactionChanged: {
if (root.transaction === null)
transactionWatchdog.stop();
else
transactionWatchdog.restart();
}
Component.onCompleted: {
root.rescan();
root.refreshActive();
restore.restart();
}
Timer {
id: restore
interval: 1500
onTriggered: {
if (!root.startupRestoreEnabled)
return;
// A stored video wallpaper restores through mpvpaper — the video
// service owns that reactively (VideoWallpaper.tryRestore), so
// this only avoids fighting it with a still policy.
if (VideoWallpaper.isVideo(String(DesktopPreferences.get("videoWallpaperPath") || ""))) {
VideoWallpaper.tryRestore();
return;
}
root.applyCurrentPolicy(false);
}
}
Timer {
id: slideshowTimer
interval: root.slideshowIntervalOverrideMs > 0
? root.slideshowIntervalOverrideMs
: DesktopPreferences.get("wallpaperIntervalMinutes") * 60000
repeat: true
running: DesktopPreferences.get("wallpaperMode") === "slideshow"
&& root.slideshowCollection.length >= 2 && !root.busy
onTriggered: root.advanceSlideshow()
}
Timer {
id: outputSettle
interval: 350
onTriggered: {
if (root.busy) {
root.pendingHotplugReapply = true;
return;
}
root.applyCurrentPolicy(false);
}
}
onOutputSignatureChanged: {
if (Object.keys(root.activeByOutput).length > 0)
outputSettle.restart();
}
function rescan(): void {
if (scan.running)
return;
root.scanning = true;
scan.running = true;
}
function refreshActive(): void {
if (!activeQuery.running && !root.busy)
activeQuery.running = true;
}
// The saved primary role anchors Panama's per-monitor choices. Keep the
// compositor's remaining order stable so a reconnect does not reshuffle
// the rest of the picker unnecessarily.
function primaryFirstOutputs(outputs: var): var {
const stored = DesktopPreferences.get("displays");
const layouts = stored && typeof stored === "object" ? stored : {};
const primary = outputs.find(output => layouts[output]?.primary === true);
return primary === undefined
? outputs
: [primary].concat(outputs.filter(output => output !== primary));
}
function candidates(extra: var): var {
const result = [];
const discovered = Array.isArray(root.candidateOverride)
? root.candidateOverride : root.available;
for (const path of discovered.concat([root.shippedPath, root.configured]).concat(extra || [])) {
if (typeof path === "string" && /^\/[^,\n]+$/.test(path) && !result.includes(path))
result.push(path);
}
return result;
}
function currentPolicy(): var {
return {
mode: DesktopPreferences.get("wallpaperMode") ?? "single",
// A stored video path cannot serve as a hyprpaper still; policies
// built while one is stored fall back to the shipped image.
globalPath: root.configured === "" || VideoWallpaper.isVideo(root.configured)
? root.shippedPath : root.configured,
collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [],
intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30,
shuffle: DesktopPreferences.get("wallpaperShuffle") !== false,
assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({}),
slideshowPath: root.slideshowPath !== "" ? root.slideshowPath : root.active
};
}
function normalizePolicy(policy: var): var {
if (!policy || typeof policy !== "object")
return null;
const mode = ["single", "slideshow", "per-monitor"].includes(policy.mode)
? policy.mode : "single";
const rawGlobal = policy.globalPath === "" ? root.shippedPath : policy.globalPath;
const candidatePaths = root.candidates([rawGlobal]
.concat(policy.collection || [])
.concat(Object.values(policy.assignments || {}))
.concat([policy.slideshowPath || ""]));
if (!WallpaperPolicy.validPath(rawGlobal, candidatePaths))
return null;
return {
mode,
globalPath: rawGlobal,
storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal,
collection: WallpaperPolicy.validCollection(policy.collection || [], candidatePaths),
intervalMinutes: Math.max(5, Math.min(1440, Number(policy.intervalMinutes) || 30)),
shuffle: policy.shuffle !== false,
assignments: WallpaperPolicy.validAssignments(policy.assignments || {}, candidatePaths),
slideshowPath: WallpaperPolicy.validPath(policy.slideshowPath, candidatePaths)
? policy.slideshowPath : rawGlobal,
nextShuffleBag: Array.isArray(policy.nextShuffleBag)
? policy.nextShuffleBag.slice() : root.shuffleBag.slice(),
nextSlideshowIndex: Number.isInteger(policy.nextSlideshowIndex)
? policy.nextSlideshowIndex : root.slideshowIndex,
candidates: candidatePaths
};
}
function applyPolicy(policy: var, persist: bool, automatic: bool): bool {
if (root.busy)
return false;
const normalized = root.normalizePolicy(policy);
if (normalized === null) {
root.lastError = "That wallpaper policy is not valid.";
// Nothing will reach schedulePendingHotplug from here, and a flag
// left standing turns the next unrelated apply into a surprise
// hotplug reapply. The signature change that armed it will arm it
// again if a display really did move.
root.pendingHotplugReapply = false;
return false;
}
const outputs = root.outputNames();
if (outputs.length === 0) {
root.lastError = "No display to set a wallpaper on.";
root.pendingHotplugReapply = false;
return false;
}
const expected = WallpaperPolicy.effectiveMap(
normalized.mode, normalized.globalPath, normalized.slideshowPath,
normalized.assignments, outputs, normalized.candidates);
if (Object.keys(expected).length !== outputs.length
|| Object.values(expected).some(path => path === "")) {
root.lastError = "That wallpaper policy is not valid.";
root.pendingHotplugReapply = false;
return false;
}
root.lastError = "";
root.transaction = {
expected,
remaining: outputs.slice(),
policy: normalized,
persist: persist === true,
automatic: automatic === true
};
root.drainTransaction();
return true;
}
function drainTransaction(): void {
if (root.transaction === null || applyProcess.running || verifyProcess.running)
return;
if (root.transaction.remaining.length === 0) {
verifyProcess.exec(["hyprctl", "hyprpaper", "listactive"]);
return;
}
const output = root.transaction.remaining[0];
root.transaction.remaining = root.transaction.remaining.slice(1);
applyProcess.exec([
"hyprctl", "hyprpaper", "wallpaper",
`${output},${root.transaction.expected[output]}`
]);
}
function parseActive(text: string): var {
const result = {};
const lines = String(text).split("\n").map(line => line.trim()).filter(line => line !== "");
for (const line of lines) {
const match = line.match(/^([A-Za-z0-9_.-]+): (\/[^,\n]+)$/);
if (!match || result[match[1]] !== undefined)
return null;
result[match[1]] = match[2];
}
return result;
}
function finishVerification(exitCode: int): void {
if (root.transaction === null)
return;
const observed = exitCode === 0 ? root.parseActive(verifyProcess.outputText) : null;
const expected = root.transaction.expected;
const matches = observed !== null
&& Object.keys(observed).length === Object.keys(expected).length
&& Object.keys(expected).every(output => observed[output] === expected[output]);
if (!matches) {
root.lastError = "Hyprpaper did not confirm that background.";
root.transaction = null;
root.abandonStillHandoff();
root.schedulePendingHotplug();
return;
}
const completed = root.transaction;
root.activeByOutput = observed;
root.transaction = null;
root.abandonStillHandoff();
root.lastError = "";
if (completed.automatic) {
root.slideshowPath = completed.policy.slideshowPath;
root.shuffleBag = completed.policy.nextShuffleBag;
root.slideshowIndex = completed.policy.nextSlideshowIndex;
}
if (completed.persist)
root.persistPolicy(completed.policy);
root.schedulePendingHotplug();
}
function persistPolicy(policy: var): void {
DesktopPreferences.set("wallpaperMode", policy.mode);
DesktopPreferences.set("wallpaperPath", policy.storedPath);
DesktopPreferences.set("wallpaperSlideshowPaths", policy.collection);
DesktopPreferences.set("wallpaperIntervalMinutes", policy.intervalMinutes);
DesktopPreferences.set("wallpaperShuffle", policy.shuffle);
DesktopPreferences.set("wallpaperPerMonitor", policy.assignments);
}
function applyCurrentPolicy(persist: bool): bool {
// While mpvpaper holds the background, hyprpaper has nothing to apply
// — hotplug is VideoWallpaper's problem and it respawns per output.
if (VideoWallpaper.active)
return true;
return root.applyPolicy(root.currentPolicy(), persist === true, false);
}
function schedulePendingHotplug(): void {
if (!root.pendingHotplugReapply)
return;
root.pendingHotplugReapply = false;
outputSettle.restart();
}
function advanceSlideshow(): bool {
if (root.busy)
return false;
const collection = root.slideshowCollection;
if (collection.length < 2)
return false;
const current = root.slideshowPath !== ""
? root.slideshowPath
: (root.active !== "" ? root.active : collection[0]);
const policy = root.currentPolicy();
policy.mode = "slideshow";
policy.collection = collection;
if (DesktopPreferences.get("wallpaperShuffle") !== false) {
const next = WallpaperPolicy.shuffledNext(
collection, root.shuffleBag, current, Math.random);
policy.slideshowPath = next.path;
policy.nextShuffleBag = next.bag;
policy.nextSlideshowIndex = collection.indexOf(next.path);
} else {
policy.slideshowPath = WallpaperPolicy.orderedNext(collection, current);
policy.nextShuffleBag = [];
policy.nextSlideshowIndex = collection.indexOf(policy.slideshowPath);
}
return root.applyPolicy(policy, false, true);
}
function setSingle(path: string): bool {
// A video routes to mpvpaper instead of hyprpaper; it still rides
// wallpaperPath so restore, backup, and the picker's "current" ring
// treat both kinds the same.
if (VideoWallpaper.isVideo(path)) {
if (!VideoWallpaper.start(path)) {
root.lastError = VideoWallpaper.lastError;
return false;
}
root.lastError = "";
DesktopPreferences.set("videoWallpaperPath", path);
return true;
}
if (DesktopPreferences.get("videoWallpaperPath") !== "")
DesktopPreferences.set("videoWallpaperPath", "");
const effectivePath = path === "" ? root.shippedPath : path;
const allowed = root.candidates([]);
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
root.lastError = "That file path cannot be used as a wallpaper.";
return false;
}
const policy = root.currentPolicy();
policy.mode = "single";
policy.globalPath = effectivePath;
policy.slideshowPath = effectivePath;
// Returning from a video: stop mpvpaper first, then offer the policy to
// hyprpaper once its service is back, retrying while it is still coming
// up rather than betting on one long wait (see attemptStillHandoff).
if (VideoWallpaper.active) {
VideoWallpaper.stop();
root.pendingStillPolicy = policy;
root.stillHandoffAttempts = 0;
stillAfterVideo.restart();
return true;
}
return root.applyPolicy(policy, true, false);
}
// The still policy waiting for hyprpaper to come back, and how many times it
// has been offered to it. A single blind wait used to stand in for this: it
// was long enough to be felt on every switch and still short enough to lose
// whenever systemd was busy, and losing meant the desktop kept the video's
// last frame with an error in Settings and no wallpaper behind it.
property var pendingStillPolicy: null
property int stillHandoffAttempts: 0
readonly property int stillHandoffLimit: 3
Timer {
id: stillAfterVideo
interval: 300
onTriggered: root.attemptStillHandoff()
}
function attemptStillHandoff(): void {
if (root.pendingStillPolicy === null)
return;
// The previous attempt's verification may still be winding down.
if (root.busy) {
stillAfterVideo.restart();
return;
}
root.stillHandoffAttempts += 1;
if (!root.applyPolicy(root.pendingStillPolicy, true, false)) {
// Rejected before hyprpaper was ever asked — retrying an invalid
// policy would only fail identically three more times.
root.abandonStillHandoff();
}
}
function abandonStillHandoff(): void {
root.pendingStillPolicy = null;
root.stillHandoffAttempts = 0;
}
function setMode(mode: string): bool {
if (!["single", "slideshow", "per-monitor"].includes(mode))
return false;
const policy = root.currentPolicy();
policy.mode = mode;
// Slideshow and per-display are hyprpaper's modes; a stored video path
// cannot serve as their global still, so fall back to the shipped one.
if (VideoWallpaper.isVideo(policy.globalPath)) {
policy.globalPath = root.shippedPath;
policy.slideshowPath = root.shippedPath;
}
if (mode === "slideshow") {
const collection = WallpaperPolicy.validCollection(
policy.collection, root.candidates([]));
if (!collection.includes(policy.slideshowPath))
policy.slideshowPath = collection[0] || policy.globalPath;
}
if (VideoWallpaper.active) {
DesktopPreferences.set("videoWallpaperPath", "");
VideoWallpaper.stop();
root.pendingStillPolicy = policy;
root.stillHandoffAttempts = 0;
stillAfterVideo.restart();
return true;
}
return root.applyPolicy(policy, true, false);
}
function setAssignment(output: string, path: string): bool {
if (!root.outputNames().includes(output)
|| !WallpaperPolicy.validPath(path, root.candidates([])))
return false;
const policy = root.currentPolicy();
policy.mode = "per-monitor";
policy.assignments = Object.assign({}, policy.assignments);
policy.assignments[output] = path;
return root.applyPolicy(policy, true, false);
}
function toggleSlideshowPath(path: string): bool {
if (!WallpaperPolicy.validPath(path, root.candidates([])))
return false;
const policy = root.currentPolicy();
const collection = WallpaperPolicy.validCollection(policy.collection, root.candidates([]));
policy.collection = collection.includes(path)
? collection.filter(candidate => candidate !== path)
: collection.concat([path]);
policy.mode = "slideshow";
if (!policy.collection.includes(policy.slideshowPath))
policy.slideshowPath = policy.collection[0] || policy.globalPath;
return root.applyPolicy(policy, true, false);
}
function setIntervalMinutes(minutes: int): bool {
const value = PreferenceSchema.coerce("wallpaperIntervalMinutes", minutes);
return value !== undefined
&& DesktopPreferences.set("wallpaperIntervalMinutes", value);
}
function setShuffle(enabled: bool): bool {
return DesktopPreferences.set("wallpaperShuffle", enabled === true);
}
// Compatibility boundary used by backup/reset and the existing picker.
function set(path: string): bool {
return root.setSingle(path);
}
function titleFor(path: string): string {
const file = String(path).split("/").pop();
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
}
}