262 lines
9.8 KiB
QML
262 lines
9.8 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 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 regenerateLock: function() { LockScreen.regenerate(); }
|
|
property var lockBusy: function() { return LockScreen.busy; }
|
|
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());
|
|
root.regenerateLock();
|
|
|
|
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() && !root.lockBusy()) || 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;
|
|
}
|
|
}
|