Shipping met reality tonight, and reality won three rounds before we did. mpvpaper's -f forks it into the background, which a supervisor reads as instant death — the respawn loop repainted the desktop black once a second. The reaper's own kills fired onExited like crashes, so the supervisor ate its young until deliberate deaths got marked. And the video's path shared wallpaperPath with the still pipeline, whose transactional persistence clobbered it — it now lives under its own videoWallpaperPath key, read by the lock screen and the doctor too. Restore is the service's own now, reactive and once per session: a one-shot timer raced the async preference load and availability probe at cold start and silently lost. A video-wallpaper IPC target drives start/stop/pause from a terminal and from the contract sweep to come. Verified live: one player per output, same PID across restarts, VAAPI engaged, 0.2% CPU steady for 1440p30 h264, pause from the bar pill. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
512 lines
20 KiB
QML
512 lines
20 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.lastError = "Hyprpaper did not apply that background.";
|
|
root.transaction = null;
|
|
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)
|
|
}
|
|
|
|
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.";
|
|
return false;
|
|
}
|
|
const outputs = root.outputNames();
|
|
if (outputs.length === 0) {
|
|
root.lastError = "No display to set a wallpaper on.";
|
|
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.";
|
|
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.schedulePendingHotplug();
|
|
return;
|
|
}
|
|
|
|
const completed = root.transaction;
|
|
root.activeByOutput = observed;
|
|
root.transaction = null;
|
|
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 give hyprpaper's
|
|
// service a beat to come back before the transaction talks to it.
|
|
if (VideoWallpaper.active) {
|
|
VideoWallpaper.stop();
|
|
root.pendingStillPolicy = policy;
|
|
stillAfterVideo.restart();
|
|
return true;
|
|
}
|
|
return root.applyPolicy(policy, true, false);
|
|
}
|
|
|
|
property var pendingStillPolicy: null
|
|
|
|
Timer {
|
|
id: stillAfterVideo
|
|
interval: 900
|
|
onTriggered: {
|
|
if (root.pendingStillPolicy) {
|
|
root.applyPolicy(root.pendingStillPolicy, true, false);
|
|
root.pendingStillPolicy = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
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, " ");
|
|
}
|
|
}
|