Files
Panama/config/dot/quickshell/services/SettingsBackup.qml
T

234 lines
8.3 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 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); }
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.";
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.";
} 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.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();
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.snapshots.some(snapshot => snapshot.name === name)) {
root.lastError = "That snapshot is not in the list.";
return false;
}
actionRun.restoring = true;
actionRun.exec([root.helperPath, "restore", name]);
return true;
}
}