Complete settings snapshot restoration

This commit is contained in:
Gabriel Brown
2026-08-18 02:22:23 -04:00
parent cae72b5179
commit 172b099a04
4 changed files with 585 additions and 63 deletions
+124 -11
View File
@@ -1,14 +1,15 @@
pragma Singleton
// Snapshots of the settings store.
// Snapshots of Panama's durable settings stores.
//
// The whole desktop configuration is one JSON file, so a backup is a copy and a
// restore is an overwrite. Worth exposing now that the settings app changes
// real things -- compositor geometry, idle timeouts, the dock -- because being
// able to return to a known-good state is what makes experimenting feel safe.
// 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.
//
// Restoring rewrites the file underneath the running shell, so the store is
// told to re-read afterwards rather than waiting for the next change.
// 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
@@ -25,6 +26,7 @@ Singleton {
property string lastAction: ""
readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running
Process {
id: listQuery
@@ -34,7 +36,8 @@ Singleton {
try {
const parsed = JSON.parse(this.text);
root.snapshots = Array.isArray(parsed) ? parsed : [];
root.lastError = "";
if (root.lastError === "Could not read the list of snapshots.")
root.lastError = "";
} catch (error) {
root.lastError = "Could not read the list of snapshots.";
}
@@ -45,6 +48,11 @@ Singleton {
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
@@ -52,14 +60,54 @@ Singleton {
: "The settings could not be backed up.";
return;
}
root.lastError = "";
root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring)
if (actionRun.restoring) {
const homeReloaded = root.reloadHomeState(actionRun.outputText);
root.lastError = homeReloaded
? ""
: "Desktop settings were restored, but Home favourites could not be reloaded.";
DesktopPreferences.reload();
applyRestoredState.restart();
} 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.
SystemSettings.applyPersistedDisplayPolicy();
Keybinds.applyReload();
Wallpaper.set(String(DesktopPreferences.get("wallpaperPath") ?? ""));
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 ((!Keybinds.reloading && !SystemSettings.busy) || attempts >= 30) {
stop();
Quickshell.reload(false);
}
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
@@ -71,7 +119,72 @@ Singleton {
if (actionRun.running)
return;
actionRun.restoring = false;
actionRun.exec([root.helperPath, "save"]);
actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);
}
function serialiseHomeState(): string {
const favorites = [];
for (const favorite of HomePreferences.favorites ?? []) {
favorites.push({
id: String(favorite.id ?? ""),
alias: String(favorite.alias ?? "")
});
}
return JSON.stringify({
initialized: HomePreferences.initialized,
favorites: favorites
});
}
// 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;
HomePreferences.resetHomeDefaults();
if (!data.initialized)
return true;
HomePreferences.initialize(ids);
for (let index = 0; index < ids.length; index++)
HomePreferences.setAlias(ids[index], aliases[index]);
return true;
} catch (error) {
return false;
}
}
function resetHomeState(): bool {
HomePreferences.resetHomeDefaults();
return true;
}
// The name is matched against the snapshot list rather than trusted, so no