Files
Panama/config/dot/quickshell/services/SystemSettings.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

658 lines
28 KiB
QML

pragma Singleton
// The allow-listed machine boundary for Panama Settings. Visual pages call
// these methods; no UI text is ever interpolated into a shell command.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
property string monitorName: ""
property string monitorDescription: ""
property int monitorWidth: 0
property int monitorHeight: 0
property real monitorRefreshRate: 0
property real monitorScale: 1
property string monitorFormat: ""
property string colorPreset: ""
property bool monitorVrrActive: false
property bool nextcloudActive: false
property bool rustdeskActive: false
property bool kdeconnectActive: false
property bool hyprpaperActive: false
property bool hypridleActive: false
property bool vicinaeActive: false
property bool bluebubblesDetected: false
property string hyprlandVersion: ""
property string quickshellVersion: "0.3.0"
property string lastError: ""
// Explicit seams keep reset sequencing testable without changing the live
// keymap, wallpaper, or display from an isolated contract harness.
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
// Installed by SettingsBackup at startup. A default no-op rather than a
// direct reference, because SettingsBackup already references this
// singleton and a mutual reference between two singletons is an
// initialisation-order problem waiting to happen. Tests override it the
// same way they override the seams above.
property var takeSafetySnapshot: function() { return false; }
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
property var applyWallpaper: function(path) { Wallpaper.set(path); }
property var regenerateLock: function() { LockScreen.regenerate(); }
property var lockBusy: function() { return LockScreen.busy; }
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
readonly property bool autoHdr: DesktopPreferences.get("autoHdr")
readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy")
readonly property int directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy")
// ── The Hyprland write boundary ─────────────────────────────────────────
// Every option Panama may write, with the hl.config path used to set it and
// the getoption path used to read it back. The UI never names an option or
// supplies a raw value: it calls a setter, which resolves the option here
// and range-checks the value against `allowed`. Nothing user-supplied is
// ever interpolated into the payload.
//
// `hyprctl keyword` is deliberately NOT used. On a Lua-configured Hyprland
// it refuses the write, prints "keyword can't work with non-legacy parsers"
// to stdout, and still exits 0 -- so code branching on the exit status
// believes it succeeded. `hyprctl eval` has the same hazard: it exits 0 on
// syntax and runtime errors, reporting them as an "error:" line instead.
//
// Success therefore means exactly one thing here: the value was read back
// from the compositor and matched what was requested.
//
// The set of writable options is not restated here: it is every schema
// entry carrying a `hypr` block. Adding a live-adjustable Hyprland setting
// is a schema entry plus a prefs.get() call in the Lua, and needs no new
// code in this file.
Process {
id: monitorQuery
command: ["hyprctl", "-j", "monitors"]
stdout: StdioCollector {
onStreamFinished: root.parseMonitors(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Could not read the active display.";
}
}
Process {
id: serviceQuery
command: [
"bash", "-lc",
"printf '{\"nextcloud\":%s,\"rustdesk\":%s,\"kdeconnect\":%s,\"hyprpaper\":%s,\"hypridle\":%s,\"vicinae\":%s}\\n' "
+ "$(pgrep -x nextcloud >/dev/null && printf true || printf false) "
+ "$(systemctl is-active --quiet rustdesk.service && printf true || printf false) "
+ "$(pgrep -x kdeconnectd >/dev/null && printf true || printf false) "
+ "$(systemctl --user is-active --quiet hyprpaper.service && printf true || printf false) "
+ "$(systemctl --user is-active --quiet hypridle.service && printf true || printf false) "
+ "$(systemctl --user is-active --quiet vicinae.service && printf true || printf false)"
]
stdout: StdioCollector {
onStreamFinished: root.parseServices(this.text)
}
}
Process {
id: versionQuery
command: ["Hyprland", "--version"]
stdout: StdioCollector {
onStreamFinished: {
const match = this.text.match(/Hyprland\s+([0-9.]+)/);
root.hyprlandVersion = match ? match[1] : this.text.trim().split("\n")[0];
}
}
}
// Applies a validated batch of options in one `hl.config{}` call, then hands
// off to configVerify. Never commits anything on its own: an "ok" here only
// means Hyprland parsed the payload.
Process {
id: configWrite
// id -> integer value, already validated by applyOptions().
property var pending: ({})
stdout: StdioCollector {
onStreamFinished: {
if (this.text.indexOf("error:") >= 0) {
root.reportWriteFailure(configWrite.pending, this.text);
return;
}
root.verifyPending();
}
}
}
Process {
id: bluebubblesQuery
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
}
// Reads the written options back out of the compositor. This is the only
// thing that decides whether a write succeeded.
Process {
id: configVerify
stdout: StdioCollector {
onStreamFinished: root.commitVerified(configWrite.pending, this.text)
}
}
Timer {
// Preferences load asynchronously from disk. Applying after one quiet
// second avoids racing their restore and runs only once per shell start.
interval: 1000
running: true
onTriggered: root.applyPersistedDisplayPolicy()
}
Component.onCompleted: root.refresh()
function refresh(): void {
root.lastError = "";
if (!monitorQuery.running)
monitorQuery.running = true;
if (!serviceQuery.running)
serviceQuery.running = true;
if (!versionQuery.running && !root.hyprlandVersion)
versionQuery.running = true;
if (!bluebubblesQuery.running)
bluebubblesQuery.running = true;
}
function parseMonitors(text: string): void {
try {
const monitors = JSON.parse(text);
const monitor = monitors.find(item => item.focused) ?? monitors[0];
if (!monitor)
throw new Error("No active monitor");
root.monitorName = monitor.name ?? "";
root.monitorDescription = monitor.description ?? monitor.model ?? "Display";
root.monitorWidth = monitor.width ?? 0;
root.monitorHeight = monitor.height ?? 0;
root.monitorRefreshRate = monitor.refreshRate ?? 0;
root.monitorScale = monitor.scale ?? 1;
root.monitorFormat = monitor.currentFormat ?? "";
root.colorPreset = monitor.colorManagementPreset ?? "";
root.monitorVrrActive = monitor.vrr ?? false;
} catch (error) {
root.lastError = "The display response could not be read.";
}
}
function parseServices(text: string): void {
try {
const state = JSON.parse(text);
root.nextcloudActive = state.nextcloud === true;
root.rustdeskActive = state.rustdesk === true;
root.kdeconnectActive = state.kdeconnect === true;
root.hyprpaperActive = state.hyprpaper === true;
root.hypridleActive = state.hypridle === true;
root.vicinaeActive = state.vicinae === true;
} catch (error) {
root.lastError = "Startup-service status could not be read.";
}
}
// ── Applying options ────────────────────────────────────────────────────
// Test harnesses may replace the external compositor boundary while still
// exercising validation, commit routing, persistence, and reset replay.
// Production leaves this unset and always uses the verified Hyprland path.
property var compositorApplyOverride: null
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
// The whole batch is validated before anything is sent, so one bad value
// rejects the batch rather than half-applying it.
function applyOptions(values: var): bool {
const requested = {};
for (const key in values) {
const entry = PreferenceSchema.spec(key);
if (!entry || !entry.hypr) {
root.lastError = "That setting is not applied by the compositor.";
return false;
}
const coerced = PreferenceSchema.coerce(key, values[key]);
if (coerced === undefined) {
root.lastError = `Unsupported value for ${entry.label}.`;
return false;
}
requested[key] = coerced;
}
if (Object.keys(requested).length === 0)
return false;
if (root.compositorApplyOverride !== null)
return root.compositorApplyOverride(requested);
// A write in flight is queued rather than refused. Options are applied
// and verified one batch at a time, but the callers are a settings UI
// and a startup replay of every compositor-backed preference -- they
// overlap routinely, and dropping a change on the floor would leave the
// stored value and the compositor disagreeing. Later values for the
// same key win.
if (configWrite.running || configVerify.running) {
root.queued = Object.assign({}, root.queued, requested);
return true;
}
root.startWrite(requested);
return true;
}
// Merged batches waiting for the current write to finish.
property var queued: ({})
function startWrite(requested: var): void {
configWrite.pending = requested;
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
}
// Called when a write settles, however it settled. A failed batch must not
// strand whatever queued up behind it.
function drainQueue(): void {
const next = root.queued;
if (Object.keys(next).length === 0)
return;
root.queued = ({});
root.startWrite(next);
}
// The value as Hyprland stores it. Several options are a toggle in the UI
// but an integer in the compositor (cm_auto_hdr, follow_mouse); `readAs`
// decides, and config/dot/hypr/prefs.lua does the same conversion via
// prefs.getInt so both sides agree.
function hyprValue(entry: var, value: var): var {
// Some options are phrased as a negative by the compositor -- the four
// Hyprland notices are all `disable_x` -- while the setting reads as
// "show x", because a switch labeled "Disable splash text" that must
// be ON to hide something is a small cruelty. `invert` bridges the two,
// in exactly one place, so nothing downstream has to remember which
// options are backwards.
if (entry.hypr.invert === true && typeof value === "boolean")
value = !value;
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
return value ? 1 : 0;
return value;
}
// Serializes validated values into a nested hl.config{} call. Table paths
// come from the schema and values have already passed coerce(), including
// the pattern check on constrained strings, so nothing caller-supplied
// reaches the payload unchecked.
function buildConfigPayload(requested: var): string {
const tree = {};
for (const key in requested) {
const entry = PreferenceSchema.spec(key);
const path = entry.hypr.path;
let node = tree;
for (let i = 0; i < path.length - 1; i++)
node = node[path[i]] = node[path[i]] ?? {};
node[path[path.length - 1]] = root.serializeValue(root.hyprValue(entry, requested[key]));
}
return `hl.config(${root.serializeTable(tree)})`;
}
function serializeValue(value: var): string {
if (typeof value === "boolean")
return value ? "true" : "false";
if (typeof value === "number")
return String(value);
// A gradient is the one setting whose Lua form is not a scalar. The
// stubs declare it as `string|{colors:string[], angle?:number}`, and
// the string form only ever carries ONE stop -- writing
// "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
// the previous value, which is how a two-stop write looks like it
// worked and did nothing. Multi-stop must be the table form.
if (value && typeof value === "object" && Array.isArray(value.colors)) {
const stops = value.colors
.map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
.join(", ");
const angle = Number(value.angle);
return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
}
// A vec2 reaches Lua as a two-element table.
if (Array.isArray(value) && value.length === 2)
return `{ ${Number(value[0])}, ${Number(value[1])} }`;
// Strings only reach here after the schema's pattern check; quoting is
// belt-and-braces rather than the primary defense.
return `"${String(value).replace(/["\\]/g, "")}"`;
}
function serializeTable(node: var): string {
const parts = [];
for (const name in node) {
const child = node[name];
// A leaf arrives pre-serialized as a string; anything else is
// either a nested section or a structured value (gradient, vec2)
// that serializeValue knows how to render.
const rendered = typeof child === "string"
? child
: (Array.isArray(child) || (child && child.colors !== undefined)
? root.serializeValue(child)
: root.serializeTable(child));
parts.push(`${name} = ${rendered}`);
}
return `{ ${parts.join(", ")} }`;
}
function verifyPending(): void {
const options = Object.keys(configWrite.pending)
.map(key => `getoption ${PreferenceSchema.spec(key).hypr.option}`)
.join(" ; ");
configVerify.exec(["hyprctl", "-j", "--batch", options]);
}
// The compositor's answer is authoritative. Preferences are only updated for
// options that actually read back with the requested value.
function commitVerified(requested: var, text: string): void {
// Each getoption answers with its own flat JSON object, and the field
// carrying the value depends on the option's type -- int, bool, float,
// str, or css for the gap box.
const observed = {};
for (const block of text.match(/\{[^{}]*\}/g) ?? []) {
try {
const parsed = JSON.parse(block);
if (parsed.option !== undefined)
observed[parsed.option] = parsed;
} catch (error) {
// A partial line is treated as "not observed", which fails the
// comparison below rather than being mistaken for success.
}
}
const rejected = [];
for (const key in requested) {
const entry = PreferenceSchema.spec(key);
if (!root.matchesObserved(entry, requested[key], observed[entry.hypr.option])) {
rejected.push(entry.label);
continue;
}
DesktopPreferences.set(key, requested[key]);
}
root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`;
root.drainQueue();
}
// Gradients are written in one notation and read back in another, so they
// cannot be compared directly the way every other type can.
//
// written: { colors = { "rgba(3b426199)" }, angle = 45 }
// read: "993b4261 45deg"
//
// The stops swap to AARRGGBB order, lose their wrapper, and the angle is
// always appended even when it was never given. Comparing the raw strings
// reports every gradient write as rejected, which is what would have
// happened had this been added with readAs: "str".
function gradientMatches(expected: var, observed: string): bool {
if (typeof observed !== "string")
return false;
return root.normalizeGradient(expected) === root.normalizeGradient(observed);
}
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
function normalizeGradient(value: var): string {
const stops = [];
let angle = 0;
const readStop = function (text: string): void {
const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
if (rgba) {
let hex = rgba[1].toLowerCase();
// rgb() has no alpha; the compositor reports it as fully opaque.
if (hex.length === 6)
hex = hex + "ff";
// RRGGBBAA in, AARRGGBB out.
stops.push(hex.slice(6, 8) + hex.slice(0, 6));
return;
}
const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
if (bare) {
stops.push(bare[1].toLowerCase());
return;
}
const deg = String(text).match(/^(-?[0-9.]+)deg$/);
if (deg)
angle = Number(deg[1]);
};
if (value && typeof value === "object" && Array.isArray(value.colors)) {
value.colors.forEach(readStop);
if (value.angle !== undefined && isFinite(Number(value.angle)))
angle = Number(value.angle);
} else {
String(value).trim().split(/\s+/).forEach(readStop);
}
return stops.join(" ") + " " + angle + "deg";
}
function matchesObserved(entry: var, value: var, answer: var): bool {
if (!answer)
return false;
const expected = root.hyprValue(entry, value);
switch (entry.hypr.readAs) {
case "bool":
return answer.bool === expected;
case "int":
return answer.int === expected;
case "float":
// getoption prints six decimal places; compare within that.
return Math.abs(answer.float - expected) < 1e-5;
case "str":
return answer.str === expected;
case "css":
// Gaps read back as a box, e.g. "10 10 10 10".
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
case "gradient":
return root.gradientMatches(expected, answer.gradient);
case "vec2":
return Array.isArray(answer.vec2) && Array.isArray(expected)
&& Number(answer.vec2[0]) === Number(expected[0])
&& Number(answer.vec2[1]) === Number(expected[1]);
}
return false;
}
function reportWriteFailure(requested: var, text: string): void {
const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label);
root.lastError = `Hyprland rejected ${labels.join(" and ")}.`;
root.drainQueue();
}
// The one entry point the settings UI uses to change any preference.
//
// A compositor-backed setting must be applied and verified before it is
// stored, so that preferences never claim a value Hyprland refused.
// Everything else is a direct write. Rows bind a schema key and call this;
// they never need to know which kind they are holding.
function commitPreference(key: string, value: var): bool {
const entry = PreferenceSchema.spec(key);
if (!entry) {
root.lastError = "That setting is not part of Panama.";
return false;
}
if (entry.hypr) {
const batch = {};
batch[key] = value;
return root.applyOptions(batch);
}
return DesktopPreferences.set(key, value);
}
// Restores shipped defaults across every store Panama owns, not just the
// schema. Panama keeps user state in more than one file -- the schema store,
// the focus session, and the Home accessory arrangement -- and a reset that
// silently skipped one would be worse than no reset at all.
//
// Compositor-backed values are re-applied afterwards, since resetting the
// stored value does not by itself tell Hyprland anything.
function restoreDefaults(): bool {
if (root.displayBusy()) {
root.lastError = "Finish the current display change before restoring defaults.";
return false;
}
// Snapshot before wiping. Restoring defaults clears every preference
// and the Home accessory store, and there is no undo for it anywhere in
// the app -- so the one automatic snapshot Panama takes is the one taken
// immediately before the only irreversible action it offers.
//
// Deliberately not fatal if it fails: a user who asked to reset should
// get their reset, and a snapshot that could not be written is reported
// rather than allowed to block the thing they asked for.
if (!root.takeSafetySnapshot())
console.warn("SystemSettings: could not snapshot before restoring defaults");
root.setDisplayBlocked(true);
DesktopPreferences.resetDesktopDefaults();
// Do not apply geometry during a reset: doing so would need the same
// visible confirmation transaction as the Displays page. Clearing the
// stored records is still important, though, so the next session uses
// Panama's shipped DP-2 placement and automatic placement elsewhere.
// Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customized
// favorites list behind while claiming to restore Panama's defaults.
//
// HomePreferences owns the write-through boundary so the state file is
// rewritten before this reset can be considered complete.
HomePreferences.resetHomeDefaults();
resettleTimer.restart();
return true;
}
Timer {
id: resettleTimer
interval: 60
onTriggered: {
root.applyPersistedDisplayPolicy();
root.reloadKeybinds();
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
root.regenerateLock();
resetRelease.attempts = 0;
resetRelease.restart();
}
}
Timer {
id: resetRelease
property int attempts: 0
interval: 100
repeat: true
onTriggered: {
attempts++;
if ((!root.keybindsReloading() && !root.busy && !root.lockBusy()) || attempts >= 50) {
stop();
root.setDisplayBlocked(false);
}
}
}
function setAutoHdr(enabled: bool): void {
root.applyOptions({ autoHdr: enabled });
}
function setVrrPolicy(policy: int): void {
root.applyOptions({ vrrPolicy: policy });
}
function setDirectScanoutPolicy(policy: int): void {
root.applyOptions({ directScanoutPolicy: policy });
}
// Replays every compositor-owned preference in one batch at shell start, so
// a value the user changed in Settings survives a reboot even though the
// Lua config only reads the file once, at launch.
function applyPersistedDisplayPolicy(): void {
const values = {};
for (const entry of PreferenceSchema.hyprEntries())
values[entry.key] = DesktopPreferences.get(entry.key);
root.applyOptions(values);
}
function isGnomePanelAllowed(panel: string): bool {
// Verified against `gnome-control-center --list` on this system. A name
// that panel list does not contain opens nothing and reports an error,
// so guessing one here would be a silently dead button.
return [
"applications", "background", "bluetooth", "color", "display",
"keyboard", "mouse", "multitasking", "network", "notifications",
"online-accounts", "power", "printers", "privacy", "search",
"sharing", "sound", "system", "universal-access", "wacom",
"wellbeing", "wifi", "wwan"
].indexOf(panel) >= 0;
}
// `subpage` reaches the panels GNOME 50 nests under System -- users,
// about, datetime, region -- which its own desktop entries open as
// `gnome-control-center system users`. Without it, a row labeled "Users"
// lands on System's front page and leaves the user to navigate, which is
// most of the way to a broken button.
function openGnomePanel(panel: string, subpage: string): bool {
if (!root.isGnomePanelAllowed(panel)) {
root.lastError = "That GNOME Settings panel is not available.";
return false;
}
const command = ["gnome-control-center", panel];
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
command.push(subpage);
Quickshell.execDetached({
command: command,
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
});
return true;
}
// Only System nests panels, and only these. Read off the Exec lines of the
// gnome-*-panel desktop entries rather than guessed, for the same reason
// the panel list above was.
function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
if (panel !== "system")
return false;
return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
}
function openApplication(id: string): bool {
const commands = {
"nextcloud": ["nextcloud"],
"rustdesk": ["rustdesk"],
"kdeconnect": ["kdeconnect-app"],
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"],
"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]
};
const command = commands[id];
if (!command) {
root.lastError = "That application is not managed by Panama Settings.";
return false;
}
Quickshell.execDetached(command);
return true;
}
}