#!/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 -> flip enabled; start/stop the app now, then persist add -> create a user entry (enabled, starts now); if the name already exists, just enable it remove -> 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()