pragma Singleton // Snapshots of Panama's durable settings stores. // // DesktopPreferences and HomePreferences use separate files. The helper owns // the transactional filesystem boundary; this service owns settling the live // desktop after those files have changed underneath it. // // HomePreferences intentionally keeps its FileView in Quickshell's private // state directory while snapshots use Panama's canonical state directory. This // service bridges them through HomePreferences' public mutation API, then soft // reloads once external consumers have settled. import Quickshell import Quickshell.Io import QtQuick import qs.config import "DisplayLayout.js" as DisplayLayout Singleton { id: root readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-backup" property var snapshots: [] property string lastError: "" // save() and create() are asynchronous: the helper is a separate process, // and the call returns long before settings.json has been read. Anything // that must not happen until the snapshot is safely on disk -- the reset in // SystemSettings.restoreDefaults is the whole reason this exists -- waits // for this rather than for the call to return. signal saveFinished(bool success) // Narrow service boundaries keep restore sequencing explicit and make it // possible to verify the real handler in an isolated shell without ever // calling the daily-driver compositor or wallpaper services. property var readHomeState: function() { return { initialized: HomePreferences.initialized, favorites: HomePreferences.favorites }; } property var resetHome: function() { HomePreferences.resetHomeDefaults(); } property var initializeHome: function(ids) { HomePreferences.initialize(ids); } property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); } property var reloadDesktop: function() { DesktopPreferences.reload(); } property var readDisplays: function() { return DesktopPreferences.get("displays"); } property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); } property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; } property var readLiveDisplayLayout: function() { return Displays.currentLayout(); } property var applyDisplayLayout: function(layout) { return Displays.applyProtectedLayout(layout); } property var displayCanConfirm: function() { return Displays.canConfirm; } property var confirmDisplayLayout: function() { return Displays.confirm(); } property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; } property var applyIdle: function() { IdleLock.apply(); } property var idleBusy: function() { return IdleLock.busy; } property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); } property var reloadKeybinds: function() { Keybinds.applyReload(); } property var keybindsReloading: function() { return Keybinds.reloading; } property var systemBusy: function() { return SystemSettings.busy; } property var applyWallpaperPolicy: function() { Wallpaper.applyCurrentPolicy(false); } property var wallpaperBusy: function() { return Wallpaper.busy; } property var regenerateLock: function() { LockScreen.regenerate(); } property var lockBusy: function() { return LockScreen.busy; } property var reloadShell: function() { Quickshell.reload(false); } property var protectedDisplays: ({}) property var protectedDisplayLayout: [] property var pendingRestoredLayout: null readonly property bool busy: listQuery.running || actionRun.running || settleDisplayRestore.running || applyRestoredState.running || settleReload.running Process { id: listQuery command: [root.helperPath, "list"] stdout: StdioCollector { onStreamFinished: { try { const parsed = JSON.parse(this.text); root.snapshots = Array.isArray(parsed) ? parsed : []; if (root.lastError === "Could not read the list of snapshots.") root.lastError = ""; } catch (error) { root.lastError = "Could not read the list of snapshots."; } } } } Process { id: actionRun property bool restoring: false // Which verb is in flight, so a failure can name what failed. A // restore has its own flag because it also drives the display handoff. property string doneAction: "saved" property string outputText: "" stdout: StdioCollector { onStreamFinished: actionRun.outputText = this.text } onStarted: actionRun.outputText = "" onExited: (exitCode, exitStatus) => { // Read before any branch below can start the next verb and change // it: only a save reports through saveFinished. const wasSave = !actionRun.restoring && actionRun.doneAction === "saved"; if (exitCode !== 0) { root.lastError = actionRun.restoring ? "That snapshot could not be restored." : actionRun.doneAction === "deleted" ? "That snapshot could not be deleted." : "The settings could not be backed up."; if (actionRun.restoring) { root.setDisplayBlocked(false); root.protectedDisplays = ({}); root.protectedDisplayLayout = []; } if (wasSave) root.saveFinished(false); return; } if (actionRun.restoring) { const restoreAccepted = root.handleRestoreOutput(actionRun.outputText); if (restoreAccepted) root.lastError = ""; else if (root.lastError === "") root.lastError = "Desktop settings were restored, but Home favorites could not be reloaded."; if (!restoreAccepted) { root.setDisplayBlocked(false); root.protectedDisplays = ({}); root.protectedDisplayLayout = []; } } else root.lastError = ""; root.refresh(); if (wasSave) root.saveFinished(true); } } Timer { id: settleDisplayRestore property int attempts: 0 interval: 100 repeat: true onTriggered: { attempts++; if (root.displayCanConfirm()) { stop(); if (!root.confirmDisplayLayout()) { root.failDisplayRestore("The restored display layout could not be confirmed."); return; } root.pendingRestoredLayout = null; root.beginRestoredStateReplay(); } else if (!root.displayBusy() || attempts >= 180) { stop(); root.failDisplayRestore("The restored display layout could not be verified."); } } } Timer { id: applyRestoredState interval: 80 repeat: false onTriggered: { // DesktopPreferences.reload() invalidates reactive shell bindings. // These services also own state outside QML and need an explicit // replay: compositor options, Lua-generated binds, and hyprpaper. root.applyIdle(); root.regenerateLock(); root.applyWallpaperPolicy(); root.reloadKeybinds(); root.applyCompositor(); settleReload.attempts = 0; settleReload.restart(); } } Timer { id: settleReload property int attempts: 0 interval: 100 repeat: true onTriggered: { attempts++; // Let the current instances finish their external writes before a // soft reload replaces them. The cap keeps a failed external tool // from leaving restored Home state stale indefinitely. if ((!root.idleBusy() && !root.keybindsReloading() && !root.systemBusy() && !root.wallpaperBusy() && !root.lockBusy()) || attempts >= 30) { stop(); root.setDisplayBlocked(false); root.protectedDisplays = ({}); root.protectedDisplayLayout = []; root.reloadShell(); } } } Component.onCompleted: { root.refresh(); // Restoring defaults is the only irreversible action Panama offers, and // it lives in SystemSettings -- which must not reference this singleton, // since this one already references it. So the capability is PUSHED // there rather than pulled from here. // // Returns false when a snapshot is already running rather than queueing: // the caller is about to wipe the stores, and a snapshot landing after // that would record the wiped state as if it were the user's. // // The return value only says the snapshot STARTED. The helper reads // settings.json in another process, so `done(success)` -- fired from // saveFinished -- is the only point at which the file on disk is known // to hold the user's settings rather than whatever the caller is about // to replace them with. The caller does its irreversible work there. SystemSettings.takeSafetySnapshot = function(done) { if (actionRun.running) return false; const handler = function(success) { root.saveFinished.disconnect(handler); if (done) done(success); }; root.saveFinished.connect(handler); root.save(); return true; }; } function refresh(): void { if (!listQuery.running) listQuery.running = true; } function save(): void { if (actionRun.running) return; actionRun.restoring = false; actionRun.doneAction = "saved"; actionRun.exec([root.helperPath, "save", root.serializeHomeState()]); } // A snapshot with a name on it. The name is passed through untouched -- // the helper owns what a usable name is, and a second sanitiser here would // be a second answer to that question, guaranteed to disagree eventually. function create(name: string): void { if (actionRun.running) return; actionRun.restoring = false; actionRun.doneAction = "saved"; actionRun.exec([root.helperPath, "create", String(name ?? ""), root.serializeHomeState()]); } // Matched against the list rather than trusted, exactly as restore() does: // no caller-supplied name reaches the helper even though it validates as // well. This is the only verb that destroys a snapshot, so the page is // expected to confirm before calling it. function deleteBackup(name: string): bool { if (actionRun.running) return false; if (!root.snapshots.some(snapshot => snapshot.name === name)) { root.lastError = "That snapshot is not in the list."; return false; } actionRun.restoring = false; actionRun.doneAction = "deleted"; actionRun.exec([root.helperPath, "delete", name]); return true; } function serializeHomeState(): string { const current = root.readHomeState(); const favorites = []; for (const favorite of current.favorites ?? []) { favorites.push({ id: String(favorite.id ?? ""), alias: String(favorite.alias ?? "") }); } return JSON.stringify({ initialized: current.initialized === true, favorites: favorites }); } function handleRestoreOutput(text: string): bool { if (!root.reloadHomeState(text)) return false; root.reloadDesktop(); const restoredLayout = root.layoutFromStoredDisplays(root.readDisplays()); if (restoredLayout === null || root.layoutsEqual( restoredLayout, root.protectedDisplayLayout)) { root.beginRestoredStateReplay(); return true; } root.pendingRestoredLayout = restoredLayout; if (!root.applyDisplayLayout(restoredLayout)) return root.failDisplayRestore("The restored display layout was rejected."); settleDisplayRestore.attempts = 0; settleDisplayRestore.restart(); return true; } function beginRestoredStateReplay(): void { applyRestoredState.restart(); } // The color, depth and mirror fields a stored arrangement may carry beyond // its geometry. Optional exactly as Displays.isPersistedLayoutEntry has // them: an arrangement written before these existed still restores, and a // field that is present but invalid refuses the whole entry rather than // being guessed at. // // Leaving them out was a real loss, not a cosmetic one. The restored record // is built on top of the LIVE layout, so a snapshot's color profile, // bit depth and SDR levels were silently replaced by whatever the display // is showing right now -- and layoutsEqual, comparing only geometry, then // judged the two identical and skipped the apply that would have put them // back. A snapshot taken in HDR restored to whatever was on screen. readonly property var storedDisplayFields: ["vrrMode", "colorProfile", "bitdepth", "sdrBrightness", "sdrSaturation", "mirrorOf"] function validStoredField(field: string, value: var): bool { switch (field) { case "vrrMode": return DisplayLayout.validVrrMode(value); case "colorProfile": return DisplayLayout.validColorProfile(value); case "bitdepth": return DisplayLayout.validBitdepth(value); case "sdrBrightness": return DisplayLayout.validSdrBrightness(value); case "sdrSaturation": return DisplayLayout.validSdrSaturation(value); case "mirrorOf": return typeof value === "string" && (value === "" || /^[A-Za-z0-9_.-]+$/.test(value)); default: return false; } } function layoutFromStoredDisplays(stored: var): var { if (!stored || typeof stored !== "object") return null; const current = root.readLiveDisplayLayout(); if (!Array.isArray(current) || current.length === 0) return null; const layout = []; for (const live of current) { const entry = stored[live.name]; const match = String(entry?.mode ?? "").match( /^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/); if (!entry || !match || !Number.isFinite(entry.scale) || entry.scale <= 0 || !Number.isInteger(entry.transform) || entry.transform < 0 || entry.transform > 3 || !Number.isInteger(entry.x) || !Number.isInteger(entry.y) || typeof entry.primary !== "boolean") return null; const record = Object.assign({}, live, { width: Number(match[1]), height: Number(match[2]), refreshRate: Number(match[3]), mode: entry.mode, scale: entry.scale, transform: entry.transform, x: entry.x, y: entry.y, primary: entry.primary }); for (const field of root.storedDisplayFields) { if (entry[field] === undefined) continue; if (!root.validStoredField(field, entry[field])) return null; record[field] = entry[field]; } layout.push(record); } return layout.filter(record => record.primary).length === 1 ? layout : null; } function layoutsEqual(left: var, right: var): bool { if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; const fields = ["name", "mode", "scale", "transform", "x", "y", "primary", "vrrMode", "colorProfile", "bitdepth", "mirrorOf"]; // Compared with a tolerance rather than by identity, the same way // Displays.storedFieldsDiffer does: these come back from the compositor // as floats, and 1.0 read back as 0.9999999 is not a change anybody made. const approximate = ["sdrBrightness", "sdrSaturation"]; const a = Array.from(left).sort((x, y) => x.name.localeCompare(y.name)); const b = Array.from(right).sort((x, y) => x.name.localeCompare(y.name)); return a.every((record, index) => fields.every( field => record[field] === b[index][field]) && approximate.every(field => { const one = record[field]; const other = b[index][field]; if (one === undefined || other === undefined) return one === other; return Math.abs(Number(one) - Number(other)) < 0.001; })); } function failDisplayRestore(message: string): bool { root.pendingRestoredLayout = null; if (!root.protectDisplays(root.protectedDisplays)) message += " The original display preference also could not be restored."; root.setDisplayBlocked(false); root.protectedDisplays = ({}); root.protectedDisplayLayout = []; root.lastError = message; return false; } // Restore output carries the canonical Home state. Reconstructing through // these methods keeps validation and persistence inside HomePreferences; // this service never mutates its aliases or private FileView directly. function reloadHomeState(text: string): bool { try { const result = JSON.parse(text); const restored = result?.home; if (!restored || restored.preserve === true) return true; if (restored.present !== true) return restored.present === false ? root.resetHomeState() : false; const data = restored.data; if (!data || typeof data.initialized !== "boolean" || !Array.isArray(data.favorites)) return false; const ids = []; const aliases = []; const seen = {}; for (const favorite of data.favorites) { const id = favorite?.id; const alias = favorite?.alias; if (typeof id !== "string" || !/^light\.[a-z0-9_]+$/.test(id) || typeof alias !== "string" || seen[id]) return false; seen[id] = true; ids.push(id); aliases.push(alias); } if (!data.initialized && ids.length > 0) return false; root.resetHome(); if (!data.initialized) return true; root.initializeHome(ids); for (let index = 0; index < ids.length; index++) root.aliasHome(ids[index], aliases[index]); return true; } catch (error) { return false; } } function resetHomeState(): bool { root.resetHome(); return true; } // The name is matched against the snapshot list rather than trusted, so no // caller-supplied path reaches the helper even though it validates as well. function restore(name: string): bool { if (actionRun.running) return false; if (root.displayBusy()) { root.lastError = "Finish the current display change before restoring settings."; return false; } if (!root.snapshots.some(snapshot => snapshot.name === name)) { root.lastError = "That snapshot is not in the list."; return false; } const currentDisplays = root.readDisplays(); root.protectedDisplays = JSON.parse(JSON.stringify( currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {})); root.protectedDisplayLayout = JSON.parse(JSON.stringify( root.readLiveDisplayLayout() ?? [])); root.setDisplayBlocked(true); actionRun.restoring = true; actionRun.exec([root.helperPath, "restore", name]); return true; } }