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); } 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); } 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 ──────────────────────────────────────────────────── // `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; // 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 { if (typeof value === "boolean" && entry.hypr.readAs !== "bool") return value ? 1 : 0; return value; } // Serialises 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.serialiseValue(root.hyprValue(entry, requested[key])); } return `hl.config(${root.serialiseTable(tree)})`; } function serialiseValue(value: var): string { if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") return String(value); // Strings only reach here after the schema's pattern check; quoting is // belt-and-braces rather than the primary defence. return `"${String(value).replace(/["\\]/g, "")}"`; } function serialiseTable(node: var): string { const parts = []; for (const name in node) { const child = node[name]; parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`); } 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(); } 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; } 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; } const currentDisplays = root.readDisplays(); const protectedDisplays = JSON.parse(JSON.stringify( currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {})); root.setDisplayBlocked(true); DesktopPreferences.resetDesktopDefaults(); if (!root.protectDisplays(protectedDisplays)) { root.setDisplayBlocked(false); root.lastError = "The current display setting could not be protected during reset."; return false; } // Home accessories keep their own store (panama-home.json), so a reset // that only cleared the schema store would silently leave a customised // favourites 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") ?? "")); resetRelease.attempts = 0; resetRelease.restart(); } } Timer { id: resetRelease property int attempts: 0 interval: 100 repeat: true onTriggered: { attempts++; if ((!root.keybindsReloading() && !root.busy) || 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; } function openGnomePanel(panel: string): bool { if (!root.isGnomePanelAllowed(panel)) { root.lastError = "That GNOME Settings panel is not available."; return false; } Quickshell.execDetached({ command: ["gnome-control-center", panel], environment: { "XDG_CURRENT_DESKTOP": "GNOME" } }); return true; } 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; } }