Files
Panama/config/dot/quickshell/services/SettingsBackup.qml
T
Gabriel Brown 2fcaada7e8 Snapshot automatically before restoring defaults
Restoring defaults clears every preference and the Home accessory store,
and nothing in the app can undo it. Snapshots existed but were entirely
manual, so the one irreversible action Panama offers was also the one
with no safety net.

It now snapshots first. Not fatal if that fails: someone who asked to
reset gets their reset, and a snapshot that could not be written is
reported rather than allowed to block what they asked for.

The wiring is inverted deliberately. SettingsBackup already references
SystemSettings, so referencing it back would make two singletons depend
on each other, which is an initialisation-order problem waiting to
happen. Instead SystemSettings exposes a seam defaulting to a no-op and
SettingsBackup installs itself into it at startup -- the same shape as
the seams the reset path already uses for test isolation.

The contract asserts the snapshot is FIRST in the call sequence, not
merely present. A snapshot taken after the stores were cleared would
faithfully record the wiped state as the user's own, which is worse than
no snapshot: it looks like a safety net and is a copy of the damage.
Verified against both mutations -- removing the snapshot, and moving it
after the wipe.

One trap, hit for the third time today: QML allows only one
Component.onCompleted per object, and SettingsBackup already had one.
Adding a second does not fail locally -- it poisons the entire services
module, so every singleton reports "Type X unavailable" and the real
error is the last line of a forty-line cascade.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 08:37:43 -04:00

381 lines
15 KiB
QML

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();
// 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.
SystemSettings.takeSafetySnapshot = function() {
if (actionRun.running)
return false;
root.save();
return true;
};
}
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;
}
}