Continues the settings expansion toward replacing GNOME Settings for everything Panama actually owns. Wallpaper. A thumbnail grid rather than a path field: the value of this setting is the picture, so typing a path to something you cannot see is the worst version of it. Two things about hyprpaper 0.8 shaped this. Its IPC is much smaller than older documentation suggests -- preload, listloaded, unload, and reload all answer "invalid hyprpaper request", so setting is a single call with no preload. And the "<empty>,<path>" form that used to mean every output is silently ignored, so a wallpaper set that way appears to succeed and never changes; outputs are walked explicitly instead. hyprpaper.conf lives in the repo through the ~/.config/hypr symlink and so cannot hold machine state, which is why the choice lives in the shared settings store and is re-applied at startup. Power & Lock. hypridle has no IPC for reconfiguration and its config is hyprlang rather than the shared JSON, so scripts/panama-idle generates a config from the settings store and restarts the daemon. The generated file lives under XDG_STATE_HOME for the same symlink reason, with a systemd drop-in pointing hypridle at it. Management is a real state and the page says which one you are in rather than showing sliders that quietly do nothing. Zero means never for all three timers, which a naive template would render as "immediately". Date & Time. Deliberately not stored in Panama's settings: the timezone and network time belong to the machine and are shared with sessions that never see this file. Storing a copy would create a second answer to a question the system already answers. Reads and writes timedatectl directly; a cancelled polkit prompt surfaces as an error rather than as a value that appears to have been accepted. Accessibility. Pointer size and text scale have to agree across three consumers with no shared configuration -- the compositor, GTK, and the shell -- so the store is the source of truth and the values are pushed outward to gsettings and hyprctl setcursor. Search now indexes the schema instead of the twelve page labels. "gaps", "wallpaper", and "screenshot" previously found nothing on an app that has all three, which is the clearest way a settings app feels smaller than it is. Shortcuts are indexed by what they do. A contract asserts every non-internal schema label is reachable, so a new setting cannot be added in an undiscoverable state. The GNOME delegation allow-list was widened to the panel names gnome-control-center actually reports; the previous list contained "users", which is not one of them and so opened nothing. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
479 lines
19 KiB
QML
479 lines
19 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: ""
|
|
|
|
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(): void {
|
|
DesktopPreferences.resetDesktopDefaults();
|
|
|
|
// 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.
|
|
//
|
|
// Done through HomePreferences' public writable aliases rather than a
|
|
// reset function of its own: clearing `favorites` and returning
|
|
// `initialized` to false is exactly the state a fresh install has, and
|
|
// it lets initialize() seed the list again on next use.
|
|
HomePreferences.favorites = [];
|
|
HomePreferences.initialized = false;
|
|
|
|
resettleTimer.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: resettleTimer
|
|
interval: 60
|
|
onTriggered: root.applyPersistedDisplayPolicy()
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|