aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/autostart/Autostart.js
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 09:54:18 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 09:54:18 +0200
commit66049c965139d821747ea76960b1e91316f8110a (patch)
tree45ac7a387ea02c1cc8d26e104f6e69a96d3880ed /desktop/modules/autostart/Autostart.js
parent4513a506fc575f880db80f05bb3a9cdf7dc100e0 (diff)
downloadquickshell-66049c965139d821747ea76960b1e91316f8110a.tar.gz
quickshell-66049c965139d821747ea76960b1e91316f8110a.zip
desktop: a settings window, and autostart as its first page
The drawer's tiles were turning into a full settings app. The drawer stays the quick area, live state and things toggled often; pages set once and left alone go to a settings window, a normal Hyprland toplevel listing them down the left. A module joins with settings: true, which also drops its tile. A gear tile and ipc call settings open|toggle reach it. Autostart edits hypr-theme's autostart.lua directly, line by line: an hl.exec_cmd line is an entry, behind -- it is disabled, everything else passes through. Edits refuse when their line moved since the parse, and writes are atomic because Hyprland reloads on every change. A wrong entry only shows at the next login, so adding is guided rather than a free text field: pick an installed app or type a command whose program must resolve on PATH before Add enables, then a workspace and silent, composed into a [workspace X silent] rule. Try runs the line through hyprctl dispatch so the rule applies as at login. Existing rows get no Try: they ran at login, and a second start stacks an instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/autostart/Autostart.js')
-rw-r--r--desktop/modules/autostart/Autostart.js129
1 files changed, 129 insertions, 0 deletions
diff --git a/desktop/modules/autostart/Autostart.js b/desktop/modules/autostart/Autostart.js
new file mode 100644
index 0000000..81510d7
--- /dev/null
+++ b/desktop/modules/autostart/Autostart.js
@@ -0,0 +1,129 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+.pragma library
+
+// Pure helpers for the autostart page, tested by test_autostart.js under node.
+//
+// autostart.lua is walked line by line. A line that is exactly
+// `hl.exec_cmd("...")` is an entry, the same behind `-- ` is a disabled entry,
+// and every other line passes through untouched. Each edit returns the new
+// file text, or null when the line it targets is no longer what was parsed
+// (the file changed underneath), so a stale page never edits the wrong line.
+
+const RE = /^(\s*)(-- ?)?hl\.exec_cmd\("((?:[^"\\]|\\.)*)"\)\s*$/;
+
+// ponytail: only \" and \\ are decoded, the two escapes the file uses; a
+// \n would show as n. Fine while commands are typed on one line.
+function unquote(s) { return s.replace(/\\(.)/g, "$1"); }
+function quote(s) { return s.replace(/[\\"]/g, c => "\\" + c); }
+
+// A command's leading `[rules]`, as hl.exec_cmd takes them, and the rest.
+function split(cmd) {
+ const m = /^\[([^\]]*)\]\s*(.*)$/.exec(cmd);
+ return m ? { rule: m[1].trim(), body: m[2] } : { rule: "", body: cmd };
+}
+
+// {ws, silent} for no rule or a lone workspace rule; null for any other rule,
+// which the page shows as is and does not offer to edit.
+function workspace(rule) {
+ if (rule === "") return { ws: "", silent: false };
+ const m = /^workspace\s+(\S+)(\s+silent)?$/.exec(rule);
+ return m ? { ws: m[1], silent: !!m[2] } : null;
+}
+
+function compose(ws, silent, body) {
+ return ws ? "[workspace " + ws + (silent ? " silent" : "") + "] " + body : body;
+}
+
+// Shell words, with quotes and backslashes resolved. ponytail: no $(...),
+// globs or operators beyond splitting at them; enough to find a program.
+function words(s) {
+ const out = [];
+ let cur = null, q = "";
+ for (let i = 0; i < s.length; i++) {
+ const c = s[i];
+ if (q) {
+ if (c === q) q = "";
+ else if (c === "\\" && q === '"' && i + 1 < s.length) cur += s[++i];
+ else cur += c;
+ } else if (c === "'" || c === '"') { q = c; cur = cur ?? ""; }
+ else if (c === "\\" && i + 1 < s.length) cur = (cur ?? "") + s[++i];
+ else if (/\s/.test(c) || ";&|<>".includes(c)) {
+ if (cur !== null) out.push(cur);
+ cur = null;
+ if (!/\s/.test(c)) out.push(c);
+ } else cur = (cur ?? "") + c;
+ }
+ if (cur !== null) out.push(cur);
+ return out;
+}
+
+// The program a command line runs, for a PATH check: past a `[rules]`
+// prefix, VAR=value assignments, env and exec, and into `sh -c '...'`.
+// `~/` is expanded against home. Empty if there is none.
+function program(cmd, home) {
+ const w = words(split(cmd).body);
+ let i = 0;
+ while (i < w.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i]) || w[i] === "env" || w[i] === "exec")) i++;
+ const p = w[i] ?? "";
+ if (/^(ba|z|da)?sh$/.test(p.replace(/.*\//, "")) && w[i + 1] === "-c" && w[i + 2] !== undefined)
+ return program(w[i + 2], home);
+ return p.startsWith("~/") ? home + p.slice(1) : p;
+}
+
+// A .desktop Exec line as a command: the field codes (%U, %f, ...) dropped,
+// %% kept as a literal %.
+function fromExec(exec) {
+ return exec.replace(/%%/g, "\0").replace(/\s*%[a-zA-Z]/g, "").replace(/\0/g, "%").trim();
+}
+
+function line(indent, cmd, enabled) {
+ return indent + (enabled ? "" : "-- ") + 'hl.exec_cmd("' + quote(cmd) + '")';
+}
+
+// [{index, raw, indent, cmd, enabled}] in file order.
+function parse(text) {
+ const out = [];
+ text.split("\n").forEach((raw, index) => {
+ const m = RE.exec(raw);
+ if (m) out.push({ index, raw, indent: m[1], cmd: unquote(m[3]), enabled: !m[2] });
+ });
+ return out;
+}
+
+function edit(text, e, fn) {
+ const a = text.split("\n");
+ if (a[e.index] !== e.raw) return null;
+ fn(a);
+ return a.join("\n");
+}
+
+function toggle(text, e) {
+ return edit(text, e, a => a[e.index] = line(e.indent, e.cmd, !e.enabled));
+}
+
+function remove(text, e) {
+ return edit(text, e, a => a.splice(e.index, 1));
+}
+
+// A new enabled entry before the hook's closing `end)`, indented like the
+// last entry. Null if there is no `end)` to put it before.
+function add(text, cmd) {
+ const a = text.split("\n");
+ let end = -1;
+ for (let i = a.length - 1; i >= 0; i--)
+ if (/^end\)\s*$/.test(a[i])) { end = i; break; }
+ if (end < 0) return null;
+ const last = parse(text).pop();
+ a.splice(end, 0, line(last ? last.indent : " ", cmd, true));
+ return a.join("\n");
+}