aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/autostart/Autostart.js
blob: 8fea82343e28ed560a16099ad876560c181a6a86 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// 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 closing `end)` of the hyprland.start hook,
// indented like the last entry. Not the file's last `end)`: a
// hyprland.shutdown hook after it would run the entry at logout. Null if
// there is no start hook with an `end)` to put it before.
function add(text, cmd) {
    const a = text.split("\n");
    const start = a.findIndex(l => /^hl\.on\(\s*"hyprland\.start"/.test(l));
    let end = -1;
    for (let i = start + 1; start >= 0 && i < a.length; 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");
}