Files
Panama/config/dot/quickshell/services/Wallpaper.qml
T
Gabriel Brown 4d7a194300 Make video wallpapers survive first contact
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
2026-08-23 23:57:04 -04:00

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, " ");
}
}