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.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user