Make Panama settings one shared source of truth
Panama had grown into three configuration surfaces that only agreed because they had been typed to agree: looks.lua hardcoded values, DesktopPreferences independently defaulted the same values, and SystemSettings replayed them at startup. Nothing kept them in sync, and the Lua side read no shared state at all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl keyword refuses the write, prints the refusal to stdout, and still exits 0, so the HDR, VRR, and direct-scanout toggles persisted their value and reported success while the compositor never changed. Writes now go through hyprctl eval, which has the same hazard on syntax and runtime errors, so success is defined as reading the value back and finding it equal. The existing contract passed throughout the outage because it re-applied the values already in place; the new one flips each value to something it does not hold. Derive preferences from a schema. Every setting used to be restated four times -- a property alias, a JSON adapter property, a change handler, and a line in reset -- where omitting any one failed silently. PreferenceSchema.qml is now the single source, and persistence, validation, reset, and the Hyprland mapping all derive from it. Unknown keys on disk survive a write so a rollback does not discard a newer build's settings, and a corrupt file falls back to shipped defaults. The store moved to ~/.config/panama/settings.json, migrating from the old state directory without deleting it. Share that file with Hyprland. prefs.lua reads it at config time with every shipped literal kept as the fallback, so the config still stands alone. The Lua is the default, the JSON is the truth, and Settings is the editor. The compositor-adjustable surface goes from 3 keys to 23. Also fixes two test-hygiene bugs found by running the suite end to end for the first time: settings-pages-contract could see the window settings-window-contract leaves behind, and the new write contract was persisting its deliberately-wrong values into the user's real store. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -33,11 +33,32 @@ Singleton {
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
||||
|| autoHdrWrite.running || vrrWrite.running || directScanoutWrite.running
|
||||
|| configWrite.running || configVerify.running
|
||||
|
||||
readonly property bool autoHdr: DesktopPreferences.autoHdr
|
||||
readonly property int vrrPolicy: DesktopPreferences.vrrPolicy
|
||||
readonly property int directScanoutPolicy: DesktopPreferences.directScanoutPolicy
|
||||
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
|
||||
@@ -79,42 +100,33 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: autoHdrWrite
|
||||
property bool requested: true
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
DesktopPreferences.autoHdr = requested;
|
||||
root.lastError = "";
|
||||
} else {
|
||||
root.lastError = "Hyprland rejected the HDR policy.";
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the written options back out of the compositor. This is the only
|
||||
// thing that decides whether a write succeeded.
|
||||
Process {
|
||||
id: vrrWrite
|
||||
property int requested: 3
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
DesktopPreferences.vrrPolicy = requested;
|
||||
root.lastError = "";
|
||||
} else {
|
||||
root.lastError = "Hyprland rejected the VRR policy.";
|
||||
}
|
||||
}
|
||||
}
|
||||
id: configVerify
|
||||
|
||||
Process {
|
||||
id: directScanoutWrite
|
||||
property int requested: 2
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
DesktopPreferences.directScanoutPolicy = requested;
|
||||
root.lastError = "";
|
||||
} else {
|
||||
root.lastError = "Hyprland rejected the direct-scanout policy.";
|
||||
}
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.commitVerified(configWrite.pending, this.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,33 +184,167 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
if (configWrite.running || configVerify.running) {
|
||||
root.lastError = "Another change is still being applied.";
|
||||
return false;
|
||||
}
|
||||
|
||||
configWrite.pending = requested;
|
||||
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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 ")}.`;
|
||||
}
|
||||
|
||||
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 ")}.`;
|
||||
}
|
||||
|
||||
function setAutoHdr(enabled: bool): void {
|
||||
autoHdrWrite.requested = enabled;
|
||||
autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]);
|
||||
root.applyOptions({ autoHdr: enabled });
|
||||
}
|
||||
|
||||
function setVrrPolicy(policy: int): void {
|
||||
if (policy !== 0 && policy !== 3) {
|
||||
root.lastError = "Unsupported VRR policy.";
|
||||
return;
|
||||
}
|
||||
vrrWrite.requested = policy;
|
||||
vrrWrite.exec(["hyprctl", "keyword", "misc:vrr", String(policy)]);
|
||||
root.applyOptions({ vrrPolicy: policy });
|
||||
}
|
||||
|
||||
function setDirectScanoutPolicy(policy: int): void {
|
||||
if (policy !== 0 && policy !== 2) {
|
||||
root.lastError = "Unsupported direct-scanout policy.";
|
||||
return;
|
||||
}
|
||||
directScanoutWrite.requested = policy;
|
||||
directScanoutWrite.exec(["hyprctl", "keyword", "render:direct_scanout", String(policy)]);
|
||||
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 {
|
||||
root.setAutoHdr(DesktopPreferences.autoHdr);
|
||||
root.setVrrPolicy(DesktopPreferences.vrrPolicy);
|
||||
root.setDirectScanoutPolicy(DesktopPreferences.directScanoutPolicy);
|
||||
const values = {};
|
||||
for (const entry of PreferenceSchema.hyprEntries())
|
||||
values[entry.key] = DesktopPreferences.get(entry.key);
|
||||
root.applyOptions(values);
|
||||
}
|
||||
|
||||
function isGnomePanelAllowed(panel: string): bool {
|
||||
|
||||
Reference in New Issue
Block a user