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 Singleton { id: root readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-backup" property var snapshots: [] property string lastError: "" property string lastAction: "" // 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 property string outputText: "" stdout: StdioCollector { onStreamFinished: actionRun.outputText = this.text } onStarted: actionRun.outputText = "" onExited: (exitCode, exitStatus) => { if (exitCode !== 0) { root.lastError = actionRun.restoring ? "That snapshot could not be restored." : "The settings could not be backed up."; if (actionRun.restoring) { root.setDisplayBlocked(false); root.protectedDisplays = ({}); root.protectedDisplayLayout = []; } return; } root.lastAction = actionRun.restoring ? "restored" : "saved"; 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(); } } 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() function refresh(): void { if (!listQuery.running) listQuery.running = true; } function save(): void { if (actionRun.running) return; actionRun.restoring = false; actionRun.exec([root.helperPath, "save", root.serializeHomeState()]); } 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(); } 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; layout.push(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 })); } 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"]; 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])); } 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; } }