More GNOME Settings parity.
Region & Language is new. The locale is localectl's, and Panama stores no
copy of it -- there is exactly one system locale, so a preference here
would be a second source of truth that drifts the moment anything else
changes it. Codes are resolved against iso-codes into "Portuguese
(Brazil)" the way GNOME does, with the code kept visible because it is
what actually gets written and someone choosing between two Spanish
variants needs to see it. Changing it is privileged and only applies to
programs started afterwards, so the page says a sign-out is needed
rather than claiming the new language is in use.
The service is called SystemLocale, not Locale: QML has a built-in
Locale value type that silently shadows a singleton of that name, and
every binding then reads properties off the wrong thing. The page
rendered empty with nothing but "cannot read property of undefined" to
explain it.
About now answers what GNOME's About answers -- model, processor,
memory, disk, OS, kernel, windowing system -- where before it listed
only Panama's own component versions. Graphics is joined from
GraphicsDevices rather than read again, because two readouts of the same
hardware are two things that can disagree. Placeholder DMI strings
("To Be Filled By O.E.M.") are filtered out, and unreadable facts are
omitted rather than shown as "Unknown".
The Fedora hand-off card was one row listing five subjects that opened
the network panel regardless. Naming a panel and then not opening it
reads as a broken button rather than a deliberate hand-off. Each subject
now opens the panel that owns it, and openGnomePanel takes an optional
subpage so "Users" reaches System's users page the way GNOME's own
desktop entry does, instead of dropping the user on System's front page.
Printers and online accounts are not repeated here; they stay with the
network hardware on Network & Devices.
One bug this surfaced, caught by the hyprland write contract: the Lua
config key and the hyprctl option name genuinely differ for tap to
click. hl.config wants input.touchpad.tap_to_click; getoption answers to
input:touchpad:tap-to-click. Either spelling used for both fails -- a
hyphen is not a Lua identifier, and the underscored name is not a known
option -- which is what the schema's two separate fields are for.
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
546 lines
22 KiB
QML
546 lines
22 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;
|
|
}
|
|
|
|
// `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 labelled "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;
|
|
}
|
|
}
|