Files
Nix-Vibe/home-manager/modules/quickshell-apps.py
T
petere eb08cd4282 Nix-Vibe public snapshot (squashed history)
Current state of main at 0240060 feat(hp-laptop): install TeleportFling from its flake. History intentionally
collapsed to a single commit; this repo mirrors only the latest state.
2026-09-19 13:53:39 +01:00

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()