287 lines
8.1 KiB
Python
287 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""QuickShell autostart app manager.
|
|
|
|
Lets the user pick which installed apps start at login, toggled live from the
|
|
gear quick-settings panel (no rebuild needed for preference changes).
|
|
|
|
Model
|
|
-----
|
|
The *pool* of candidates is declared in Nix (host config) and written to
|
|
~/.config/quickshell/autostartseed.json. The runtime *state* lives in
|
|
~/.cache/quickshell/autostart.json and is authoritative for on/off and for
|
|
apps the user adds from the UI (source = "user"). On every `list`/`run` the
|
|
seed is merged in: new seed entries are added disabled and, for existing seed
|
|
entries, the Nix command is re-applied while keeping the enabled flag. A
|
|
user-added app named like a seed entry wins (the seed never re-enables it).
|
|
|
|
Commands
|
|
--------
|
|
list -> JSON {apps: [{name, cmd, enabled, source}]} (after merge)
|
|
toggle <name> -> flip enabled; start/stop the app now, then persist
|
|
add <name> <cmd> -> create a user entry (enabled, starts now); if the name
|
|
already exists, just enable it
|
|
remove <name> -> stop (best-effort) and forget the entry
|
|
run -> login autostart: launch every enabled app (no dupes)
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
|
|
HOME = os.path.expanduser("~")
|
|
CACHE_DIR = os.path.join(HOME, ".cache", "quickshell")
|
|
STATE_FILE = os.path.join(CACHE_DIR, "autostart.json")
|
|
SEED_FILE = os.path.join(HOME, ".config", "quickshell", "autostartseed.json")
|
|
|
|
PGREP = "/run/current-system/sw/bin/pgrep"
|
|
PKILL = "/run/current-system/sw/bin/pkill"
|
|
|
|
|
|
def ensure_cache_dir():
|
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
|
|
|
|
def load_state():
|
|
try:
|
|
with open(STATE_FILE) as fh:
|
|
data = json.load(fh)
|
|
except (OSError, ValueError):
|
|
data = {"apps": []}
|
|
if not isinstance(data, dict) or not isinstance(data.get("apps"), list):
|
|
data = {"apps": []}
|
|
return data
|
|
|
|
|
|
def save_state(data):
|
|
ensure_cache_dir()
|
|
with open(STATE_FILE, "w") as fh:
|
|
json.dump(data, fh, indent=2)
|
|
|
|
|
|
def load_seed():
|
|
try:
|
|
with open(SEED_FILE) as fh:
|
|
seed = json.load(fh)
|
|
except (OSError, ValueError):
|
|
return []
|
|
if not isinstance(seed, list):
|
|
return []
|
|
out = []
|
|
for entry in seed:
|
|
if isinstance(entry, dict) and entry.get("name") and entry.get("cmd"):
|
|
out.append({"name": str(entry["name"]), "cmd": str(entry["cmd"])})
|
|
return out
|
|
|
|
|
|
def merge_seed(data):
|
|
"""Apply the Nix-declared pool to the runtime state (add-missing only)."""
|
|
seed = load_seed()
|
|
for s in seed:
|
|
found = next(
|
|
(a for a in data["apps"] if a.get("name", "").lower() == s["name"].lower()),
|
|
None,
|
|
)
|
|
if found is None:
|
|
data["apps"].append(
|
|
{"name": s["name"], "cmd": s["cmd"], "enabled": False, "source": "seed"}
|
|
)
|
|
elif found.get("source") == "seed":
|
|
found["cmd"] = s["cmd"]
|
|
return data
|
|
|
|
|
|
def find_app(data, name):
|
|
return next(
|
|
(a for a in data["apps"] if a.get("name", "").lower() == name.lower()), None
|
|
)
|
|
|
|
|
|
def binary_of(cmd):
|
|
try:
|
|
toks = shlex.split(cmd)
|
|
except ValueError:
|
|
return None
|
|
return os.path.basename(toks[0]) if toks else None
|
|
|
|
|
|
def pattern_of(cmd):
|
|
"""pgrep/pkill -f pattern for the app's executable.
|
|
|
|
Electron apps (element-desktop, bitwarden-desktop) run with comm=electron,
|
|
so comm (-x) matching misses them and we match the full command line
|
|
instead. The bracket trick 'e[e]lement-desktop' still matches
|
|
element-desktop but never the pgrep/pkill command itself (its own cmdline
|
|
contains the literal bracketed form) — avoiding the classic -f self-match.
|
|
"""
|
|
binary = binary_of(cmd)
|
|
if not binary:
|
|
return None
|
|
if len(binary) < 2:
|
|
return binary
|
|
return binary[:1] + "[" + binary[1:2] + "]" + binary[2:]
|
|
|
|
|
|
def _self_and_ancestors():
|
|
"""PIDs of this process and its parent chain.
|
|
|
|
The -f guard can be fooled by the *invoking* process: `qs-apps add X "cmd"`
|
|
passes the command as an argv element, so our own cmdline (and ancestors
|
|
like a `sh -c 'qs-apps add X cmd'` wrapper) contains the app's binary name
|
|
and pgrep -f would match it instead of the real app. Excluding the whole
|
|
ancestor chain makes the guard see only genuinely running apps.
|
|
"""
|
|
pids = {os.getpid()}
|
|
ppid = os.getppid()
|
|
seen = set()
|
|
while ppid > 1 and ppid not in seen:
|
|
seen.add(ppid)
|
|
pids.add(ppid)
|
|
try:
|
|
with open("/proc/%d/stat" % ppid) as fh:
|
|
parts = fh.read().split()
|
|
ppid = int(parts[3])
|
|
except (OSError, IndexError, ValueError):
|
|
break
|
|
return pids
|
|
|
|
|
|
def is_running(pattern):
|
|
if not pattern:
|
|
return False
|
|
try:
|
|
r = subprocess.run([PGREP, "-f", pattern], capture_output=True, text=True)
|
|
except OSError:
|
|
return False
|
|
if r.returncode != 0:
|
|
return False
|
|
self_pids = _self_and_ancestors()
|
|
for line in r.stdout.splitlines():
|
|
try:
|
|
pid = int(line.strip())
|
|
except ValueError:
|
|
continue
|
|
if pid not in self_pids:
|
|
return True
|
|
return False
|
|
|
|
|
|
def start_app(cmd):
|
|
"""Launch cmd detached. Returns True if it is (or already is) running."""
|
|
try:
|
|
toks = shlex.split(cmd)
|
|
except ValueError:
|
|
return False
|
|
if not toks:
|
|
return False
|
|
pattern = pattern_of(cmd)
|
|
if pattern and is_running(pattern):
|
|
return True
|
|
try:
|
|
subprocess.Popen(
|
|
toks,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def stop_app(cmd):
|
|
"""Best-effort kill by -f pattern (comm is 'electron' for Electron apps)."""
|
|
pattern = pattern_of(cmd)
|
|
if not pattern:
|
|
return
|
|
try:
|
|
subprocess.run([PKILL, "-f", pattern], capture_output=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def cmd_toggle(name):
|
|
data = merge_seed(load_state())
|
|
app = find_app(data, name)
|
|
if app is None:
|
|
return {"ok": False, "error": "no app named " + name}
|
|
app["enabled"] = not app["enabled"]
|
|
if app["enabled"]:
|
|
started = start_app(app["cmd"])
|
|
else:
|
|
stop_app(app["cmd"])
|
|
started = True
|
|
save_state(data)
|
|
return {"ok": started, "enabled": app["enabled"]}
|
|
|
|
|
|
def cmd_add(name, cmd):
|
|
data = merge_seed(load_state())
|
|
app = find_app(data, name)
|
|
if app is None:
|
|
data["apps"].append(
|
|
{"name": name, "cmd": cmd, "enabled": True, "source": "user"}
|
|
)
|
|
app = data["apps"][-1]
|
|
else:
|
|
app["enabled"] = True
|
|
if app.get("source") == "user":
|
|
app["cmd"] = cmd
|
|
started = start_app(app["cmd"])
|
|
save_state(data)
|
|
return {"ok": started}
|
|
|
|
|
|
def cmd_remove(name):
|
|
data = merge_seed(load_state())
|
|
app = find_app(data, name)
|
|
if app is None:
|
|
return {"ok": True}
|
|
stop_app(app["cmd"])
|
|
data["apps"] = [a for a in data["apps"] if a is not app]
|
|
save_state(data)
|
|
return {"ok": True}
|
|
|
|
|
|
def cmd_run():
|
|
data = merge_seed(load_state())
|
|
for app in data["apps"]:
|
|
if app.get("enabled"):
|
|
start_app(app["cmd"])
|
|
save_state(data)
|
|
return {"ok": True}
|
|
|
|
|
|
def cmd_list():
|
|
data = merge_seed(load_state())
|
|
save_state(data)
|
|
return {"apps": data["apps"]}
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
cmd = args[0] if args else "list"
|
|
|
|
if cmd == "list":
|
|
out = cmd_list()
|
|
print(json.dumps(out))
|
|
elif cmd == "run":
|
|
print(json.dumps(cmd_run()))
|
|
elif cmd == "toggle":
|
|
name = args[1] if len(args) > 1 else ""
|
|
print(json.dumps(cmd_toggle(name)))
|
|
elif cmd == "add":
|
|
name = args[1] if len(args) > 1 else ""
|
|
command = args[2] if len(args) > 2 else ""
|
|
print(json.dumps(cmd_add(name, command)))
|
|
elif cmd == "remove":
|
|
name = args[1] if len(args) > 1 else ""
|
|
print(json.dumps(cmd_remove(name)))
|
|
else:
|
|
print(json.dumps({"ok": False, "error": "unknown command: " + cmd}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |