363 lines
14 KiB
QML
363 lines
14 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 favourites 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()
|
|
|
|
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();
|
|
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;
|
|
}
|
|
}
|