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

489 lines
20 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
import "DisplayLayout.js" as DisplayLayout
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-backup"
property var snapshots: []
property string lastError: ""
// save() and create() are asynchronous: the helper is a separate process,
// and the call returns long before settings.json has been read. Anything
// that must not happen until the snapshot is safely on disk -- the reset in
// SystemSettings.restoreDefaults is the whole reason this exists -- waits
// for this rather than for the call to return.
signal saveFinished(bool success)
// 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
// Which verb is in flight, so a failure can name what failed. A
// restore has its own flag because it also drives the display handoff.
property string doneAction: "saved"
property string outputText: ""
stdout: StdioCollector {
onStreamFinished: actionRun.outputText = this.text
}
onStarted: actionRun.outputText = ""
onExited: (exitCode, exitStatus) => {
// Read before any branch below can start the next verb and change
// it: only a save reports through saveFinished.
const wasSave = !actionRun.restoring && actionRun.doneAction === "saved";
if (exitCode !== 0) {
root.lastError = actionRun.restoring
? "That snapshot could not be restored."
: actionRun.doneAction === "deleted"
? "That snapshot could not be deleted."
: "The settings could not be backed up.";
if (actionRun.restoring) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
}
if (wasSave)
root.saveFinished(false);
return;
}
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();
if (wasSave)
root.saveFinished(true);
}
}
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.
//
// The return value only says the snapshot STARTED. The helper reads
// settings.json in another process, so `done(success)` -- fired from
// saveFinished -- is the only point at which the file on disk is known
// to hold the user's settings rather than whatever the caller is about
// to replace them with. The caller does its irreversible work there.
SystemSettings.takeSafetySnapshot = function(done) {
if (actionRun.running)
return false;
const handler = function(success) {
root.saveFinished.disconnect(handler);
if (done)
done(success);
};
root.saveFinished.connect(handler);
root.save();
return true;
};
}
function refresh(): void {
if (!listQuery.running)
listQuery.running = true;
}
function save(): void {
if (actionRun.running)
return;
actionRun.restoring = false;
actionRun.doneAction = "saved";
actionRun.exec([root.helperPath, "save", root.serializeHomeState()]);
}
// A snapshot with a name on it. The name is passed through untouched --
// the helper owns what a usable name is, and a second sanitiser here would
// be a second answer to that question, guaranteed to disagree eventually.
function create(name: string): void {
if (actionRun.running)
return;
actionRun.restoring = false;
actionRun.doneAction = "saved";
actionRun.exec([root.helperPath, "create", String(name ?? ""),
root.serializeHomeState()]);
}
// Matched against the list rather than trusted, exactly as restore() does:
// no caller-supplied name reaches the helper even though it validates as
// well. This is the only verb that destroys a snapshot, so the page is
// expected to confirm before calling it.
function deleteBackup(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 = false;
actionRun.doneAction = "deleted";
actionRun.exec([root.helperPath, "delete", name]);
return true;
}
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();
}
// The colour, depth and mirror fields a stored arrangement may carry beyond
// its geometry. Optional exactly as Displays.isPersistedLayoutEntry has
// them: an arrangement written before these existed still restores, and a
// field that is present but invalid refuses the whole entry rather than
// being guessed at.
//
// Leaving them out was a real loss, not a cosmetic one. The restored record
// is built on top of the LIVE layout, so a snapshot's colour profile,
// bit depth and SDR levels were silently replaced by whatever the display
// is showing right now -- and layoutsEqual, comparing only geometry, then
// judged the two identical and skipped the apply that would have put them
// back. A snapshot taken in HDR restored to whatever was on screen.
readonly property var storedDisplayFields:
["vrrMode", "colorProfile", "bitdepth", "sdrBrightness", "sdrSaturation", "mirrorOf"]
function validStoredField(field: string, value: var): bool {
switch (field) {
case "vrrMode": return DisplayLayout.validVrrMode(value);
case "colorProfile": return DisplayLayout.validColorProfile(value);
case "bitdepth": return DisplayLayout.validBitdepth(value);
case "sdrBrightness": return DisplayLayout.validSdrBrightness(value);
case "sdrSaturation": return DisplayLayout.validSdrSaturation(value);
case "mirrorOf": return typeof value === "string"
&& (value === "" || /^[A-Za-z0-9_.-]+$/.test(value));
default: return false;
}
}
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;
const record = 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
});
for (const field of root.storedDisplayFields) {
if (entry[field] === undefined)
continue;
if (!root.validStoredField(field, entry[field]))
return null;
record[field] = entry[field];
}
layout.push(record);
}
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",
"vrrMode", "colorProfile", "bitdepth", "mirrorOf"];
// Compared with a tolerance rather than by identity, the same way
// Displays.storedFieldsDiffer does: these come back from the compositor
// as floats, and 1.0 read back as 0.9999999 is not a change anybody made.
const approximate = ["sdrBrightness", "sdrSaturation"];
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])
&& approximate.every(field => {
const one = record[field];
const other = b[index][field];
if (one === undefined || other === undefined)
return one === other;
return Math.abs(Number(one) - Number(other)) < 0.001;
}));
}
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;
}
}