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 setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; } 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 currentWallpaper: function() { return String(DesktopPreferences.get("wallpaperPath") ?? ""); } property var applyWallpaper: function(path) { Wallpaper.set(path); } property var reloadShell: function() { Quickshell.reload(false); } property var protectedDisplays: ({}) readonly property bool busy: listQuery.running || actionRun.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 = ({}); } return; } root.lastAction = actionRun.restoring ? "restored" : "saved"; if (actionRun.restoring) { const homeReloaded = root.handleRestoreOutput(actionRun.outputText); root.lastError = homeReloaded ? "" : "Desktop settings were restored, but Home favourites could not be reloaded."; if (!homeReloaded) { root.setDisplayBlocked(false); root.protectedDisplays = ({}); } } else root.lastError = ""; root.refresh(); } } 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.applyCompositor(); root.reloadKeybinds(); root.applyWallpaper(root.currentWallpaper()); 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.keybindsReloading() && !root.systemBusy()) || attempts >= 30) { stop(); root.setDisplayBlocked(false); root.protectedDisplays = ({}); 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.serialiseHomeState()]); } function serialiseHomeState(): 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(); if (!root.protectDisplays(root.protectedDisplays)) return false; applyRestoredState.restart(); return true; } // 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.setDisplayBlocked(true); actionRun.restoring = true; actionRun.exec([root.helperPath, "restore", name]); return true; } }