Files
Panama/config/dot/quickshell/services/SystemSettings.qml

529 lines
21 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); }
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 ────────────────────────────────────────────────────
// 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 {
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;
}
}