mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 18:01:47 +01:00
336 lines
13 KiB
Python
336 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Runs ON the Frame (piped over SSH, with frame_steam.py): what game is running,
|
|
and force quit it.
|
|
|
|
Steam starts every game, non-Steam shortcut and devkit title under its own
|
|
`reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper stays
|
|
the parent of the game's processes. That's how Steam starts games on Linux
|
|
desktops and the Steam Deck; on the Frame it is **inferred, not yet seen**. So a
|
|
running game is a reaper with an AppId, and the game is that reaper's process
|
|
tree. Nothing is matched by name, so Steam, gamescope, SteamVR, sshd or the
|
|
session can't be picked by mistake, and the names in PROTECTED are skipped
|
|
even inside a game's tree.
|
|
|
|
Quitting asks Steam first (SteamClient.Apps.TerminateApp, what Steam's own Stop
|
|
uses for Android apps, docs/vr-apks.md). If the game's processes are still
|
|
there a few seconds later, they get SIGTERM, then SIGKILL.
|
|
|
|
Usage: python3 - list # {"running": [{appid, gameid, name, kind, ...}]}
|
|
python3 - quit APPID # {"name", "method": "steam"|"term"|"kill"|"gone", "message"}
|
|
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
|
|
"""
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
from frame_steam import Fail, Page
|
|
|
|
# The Frame is Linux; "/" also keeps the unit tests' paths the same on Windows.
|
|
HOME = os.path.expanduser("~")
|
|
STEAM = HOME + "/.local/share/Steam"
|
|
DEVKIT = HOME + "/devkit-game"
|
|
ANDROID = HOME + "/Applications/Android" # frame_android.APPS_DIR
|
|
SIGTERM, SIGKILL = signal.SIGTERM, getattr(signal, "SIGKILL", 9) # Windows (unit tests) has no SIGKILL
|
|
SHORTCUT_BIT = 0x80000000 # non-Steam shortcut ids have the top bit set
|
|
APPID_ARG = re.compile(r"AppId=(\d{1,10})")
|
|
# Never signalled, even when found under a game's reaper: Steam, the
|
|
# compositors, SteamVR, the session, audio, sshd and init.
|
|
PROTECTED = frozenset({
|
|
"steam", "steamwebhelper", "gamescope", "gamescope-wl", "gamescopereaper", "gamescope-session",
|
|
"sshd", "sshd-session", "ssh-agent", "systemd", "init", "dbus-daemon", "dbus-broker", "dbus-broker-lau",
|
|
"pipewire", "pipewire-pulse", "wireplumber", "plasmashell", "kwin_wayland", "kwin_x11", "Xwayland",
|
|
"sddm", "vrserver", "vrcompositor", "vrmonitor", "vrdashboard", "vrwebhelper", "vrstartup",
|
|
})
|
|
# Steam launches SteamVR under a reaper too, but on the Frame it's the headset's view,
|
|
# not a game: it's never listed or quit (SteamVR and SteamVR Beta).
|
|
NOT_GAMES = frozenset({250820, 1009850})
|
|
STEAM_WAIT = 5 # seconds for Steam's own terminate to end the game
|
|
TERM_WAIT = 3 # then SIGTERM, and this long before SIGKILL
|
|
KILL_WAIT = 2
|
|
|
|
RUNNING_JS = """((ids) => {
|
|
const store = globalThis.SteamUIStore;
|
|
const running = Array.isArray(store?.RunningApps) ? store.RunningApps.map(a => a.appid) : null;
|
|
const names = {};
|
|
for (const id of new Set([...ids, ...(running || [])])) {
|
|
const a = appStore.GetAppOverviewByAppID(id);
|
|
if (a) names[id] = a.display_name;
|
|
}
|
|
return {running, names};
|
|
})(%s)"""
|
|
|
|
|
|
def read(path, mode="r"):
|
|
try:
|
|
with open(path, mode) as f:
|
|
return f.read()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def snapshot(proc="/proc"):
|
|
"""pid -> {ppid, start, state, uid, comm, argv} for every process that can be read."""
|
|
out = {}
|
|
for d in os.listdir(proc):
|
|
if not d.isdigit():
|
|
continue
|
|
stat = read(f"{proc}/{d}/stat")
|
|
try:
|
|
# comm is in parentheses and may hold spaces or ')': split at the last ')'.
|
|
comm = stat[stat.index("(") + 1:stat.rindex(")")]
|
|
fields = stat[stat.rindex(")") + 2:].split()
|
|
uid = os.stat(f"{proc}/{d}").st_uid
|
|
except (AttributeError, ValueError, OSError):
|
|
continue
|
|
argv = (read(f"{proc}/{d}/cmdline", "rb") or b"").split(b"\0")
|
|
out[int(d)] = {"ppid": int(fields[1]), "state": fields[0], "start": int(fields[19]), "uid": uid,
|
|
"comm": comm, "argv": [a.decode(errors="replace") for a in argv if a]}
|
|
return out
|
|
|
|
|
|
def launch_appid(argv):
|
|
"""The AppId of `reaper SteamLaunch AppId=<id> -- ...`, or None for any other process."""
|
|
if not argv or os.path.basename(argv[0]) != "reaper" or "SteamLaunch" not in argv:
|
|
return None
|
|
for arg in argv[1:]:
|
|
if arg == "--":
|
|
break
|
|
m = APPID_ARG.fullmatch(arg)
|
|
if m:
|
|
return int(m.group(1))
|
|
return None
|
|
|
|
|
|
def reapers(procs, uid):
|
|
"""appid -> [reaper pid], for this user's game launches."""
|
|
out = {}
|
|
for pid, p in procs.items():
|
|
appid = launch_appid(p["argv"])
|
|
if appid and appid not in NOT_GAMES and p["uid"] == uid and p["state"] != "Z":
|
|
out.setdefault(appid, []).append(pid)
|
|
return out
|
|
|
|
|
|
def children(procs):
|
|
kids = {}
|
|
for pid, p in procs.items():
|
|
kids.setdefault(p["ppid"], []).append(pid)
|
|
return kids
|
|
|
|
|
|
def protected(procs, me, uid):
|
|
"""Pids never to signal: init, this helper and its parents (the SSH session),
|
|
other users' processes and anything named in PROTECTED."""
|
|
keep, pid = {0, 1}, me
|
|
while pid > 1 and pid not in keep:
|
|
keep.add(pid)
|
|
pid = procs.get(pid, {}).get("ppid", 0)
|
|
for pid, p in procs.items():
|
|
argv0 = os.path.basename(p["argv"][0]) if p["argv"] else ""
|
|
if p["uid"] != uid or p["comm"] in PROTECTED or argv0 in PROTECTED:
|
|
keep.add(pid)
|
|
return keep
|
|
|
|
|
|
def tree(procs, roots, keep):
|
|
"""The roots and their descendants, deepest first, leaving out kept processes
|
|
and everything under them."""
|
|
kids, out, level = children(procs), [], [r for r in roots if r not in keep]
|
|
while level:
|
|
out = level + out
|
|
level = [c for pid in level for c in kids.get(pid, ()) if c not in keep and procs[c]["state"] != "Z"]
|
|
return out
|
|
|
|
|
|
def alive(procs, pid, start):
|
|
p = procs.get(pid)
|
|
return bool(p) and p["start"] == start and p["state"] != "Z"
|
|
|
|
|
|
def escalate(appid, me=None, uid=None, scan=snapshot, kill=os.kill, sleep=time.sleep, clock=time.monotonic):
|
|
"""SIGTERM the game's processes, then SIGKILL what's left. The reaper itself
|
|
is only killed last, so its orphans can't escape to init before they're
|
|
signalled. Returns "term", "kill" or "gone"; raises Fail if any survive."""
|
|
me = os.getpid() if me is None else me
|
|
uid = os.getuid() if uid is None else uid
|
|
seen = {} # pid -> start time, so a reused pid is never signalled
|
|
|
|
def targets(include_roots):
|
|
procs = scan()
|
|
keep = protected(procs, me, uid)
|
|
roots = reapers(procs, uid).get(appid, [])
|
|
for pid in tree(procs, roots, keep):
|
|
seen.setdefault(pid, procs[pid]["start"])
|
|
return procs, [pid for pid, start in seen.items() if alive(procs, pid, start) and pid not in keep
|
|
and (include_roots or pid not in roots)]
|
|
|
|
def wait(seconds, include_roots):
|
|
end = clock() + seconds
|
|
while True:
|
|
procs, left = targets(include_roots)
|
|
if not left or clock() >= end:
|
|
return left
|
|
sleep(0.2)
|
|
|
|
method = "gone"
|
|
for sig, seconds, include_roots in ((SIGTERM, TERM_WAIT, False), (SIGKILL, KILL_WAIT, True)):
|
|
_, left = targets(include_roots)
|
|
if not left:
|
|
# The reaper exits when its last child does; a lingering one is all that's left.
|
|
_, left = targets(True)
|
|
if not left:
|
|
return method
|
|
for pid in left:
|
|
try:
|
|
kill(pid, sig)
|
|
except ProcessLookupError:
|
|
pass
|
|
except PermissionError:
|
|
continue
|
|
method = "term" if sig == SIGTERM else "kill"
|
|
if not wait(seconds, True):
|
|
return method
|
|
raise Fail(f"{len(targets(True)[1])} of its processes are still running after SIGKILL")
|
|
|
|
|
|
def gameid(appid):
|
|
"""The game id Steam's Apps API takes: the appid, or (appid << 32) | 0x02000000
|
|
for a non-Steam shortcut (docs/apks.md)."""
|
|
return str((appid << 32) | 0x02000000 if appid >= SHORTCUT_BIT else appid)
|
|
|
|
|
|
def android_apps():
|
|
"""Shortcut or instance id -> Frame Control's Android app meta."""
|
|
out = {}
|
|
for path in glob.glob(os.path.join(ANDROID, "*/meta.json")):
|
|
try:
|
|
m = json.loads(read(path) or "null")
|
|
except ValueError:
|
|
continue
|
|
if isinstance(m, dict) and m.get("package"):
|
|
for key in ("shortcut", "instance"):
|
|
if str(m.get(key) or "").isdigit():
|
|
out[int(m[key])] = m
|
|
return out
|
|
|
|
|
|
def lepton_containers():
|
|
try:
|
|
r = subprocess.run(["podman", "ps", "--format", "{{.Names}}"], capture_output=True, text=True, timeout=10)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return set()
|
|
return set(r.stdout.split())
|
|
|
|
|
|
def manifest_name(appid):
|
|
m = re.search(r'"name"\s+"([^"]*)"', read(os.path.join(STEAM, f"steamapps/appmanifest_{appid}.acf")) or "")
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def devkit_title(procs, pids):
|
|
"""A devkit title's id from its processes' arguments (~/devkit-game/<id>/...)."""
|
|
prefix = DEVKIT + "/"
|
|
for pid in pids:
|
|
for arg in procs[pid]["argv"]:
|
|
arg = arg.strip('"')
|
|
if arg.startswith(prefix) and "/" in arg[len(prefix):]:
|
|
return arg[len(prefix):].split("/", 1)[0]
|
|
return None
|
|
|
|
|
|
def steam_view(ids, page=None):
|
|
"""Steam's own running list and app names, or (None, {}) without Steam."""
|
|
try:
|
|
r = (page or Page()).eval(RUNNING_JS % json.dumps(sorted(ids)))
|
|
except (Fail, OSError):
|
|
return None, {}
|
|
return r.get("running"), {int(k): v for k, v in (r.get("names") or {}).items()}
|
|
|
|
|
|
def running(procs=None, uid=None, steam=steam_view, android=android_apps, containers=lepton_containers):
|
|
"""Every game running now: reapers on this Frame, Steam's running list, and
|
|
Android apps whose Lepton container is up."""
|
|
procs = snapshot() if procs is None else procs
|
|
uid = os.getuid() if uid is None else uid
|
|
found = reapers(procs, uid)
|
|
apps = android()
|
|
up = containers() if apps else set()
|
|
for m in apps.values():
|
|
if f"lepton-steamlaunch-{m.get('instance')}" in up and str(m.get("shortcut") or "").isdigit():
|
|
found.setdefault(int(m["shortcut"]), [])
|
|
listed, names = steam(set(found))
|
|
for appid in listed or ():
|
|
if int(appid) not in NOT_GAMES:
|
|
found.setdefault(int(appid), [])
|
|
out = []
|
|
for appid, roots in sorted(found.items()):
|
|
pids = tree(procs, roots, set())
|
|
app = {"appid": appid, "gameid": gameid(appid), "processes": len(pids)}
|
|
meta = apps.get(appid)
|
|
title = devkit_title(procs, pids)
|
|
if meta:
|
|
app.update(kind="android", package=meta["package"], name=names.get(appid) or meta.get("label") or meta["package"])
|
|
elif title:
|
|
app.update(kind="devkit", title=title, name=names.get(appid) or title)
|
|
elif appid >= SHORTCUT_BIT:
|
|
app.update(kind="shortcut", name=names.get(appid) or f"Shortcut {appid}")
|
|
else:
|
|
app.update(kind="steam", name=names.get(appid) or manifest_name(appid) or f"App {appid}")
|
|
out.append(app)
|
|
return out
|
|
|
|
|
|
def quit_game(appid, sleep=time.sleep, clock=time.monotonic):
|
|
apps = {a["appid"]: a for a in running()}
|
|
app = apps.get(appid)
|
|
if not app:
|
|
return {"name": None, "method": "gone", "message": "That game isn't running"}
|
|
name = app["name"]
|
|
try:
|
|
page = Page()
|
|
page.eval(f"SteamClient.Apps.TerminateApp({json.dumps(app['gameid'])}, false)")
|
|
except (Fail, OSError):
|
|
page = None # Steam isn't answering: the processes are all that's left to stop
|
|
end = clock() + (STEAM_WAIT if page else 0)
|
|
while True:
|
|
procs = snapshot()
|
|
left = reapers(procs, os.getuid()).get(appid) or (page and appid in (steam_view({appid}, page)[0] or ()))
|
|
if not left:
|
|
return {"name": name, "method": "steam" if page else "gone", "message": f"Quit {name}"}
|
|
if clock() >= end:
|
|
break
|
|
sleep(0.5)
|
|
if not reapers(procs, os.getuid()).get(appid):
|
|
# Steam still lists it, but there's no process of it to stop.
|
|
raise Fail(f"Steam didn't quit {name}, and none of its processes were found on the Frame")
|
|
method = escalate(appid)
|
|
return {"name": name, "method": method,
|
|
"message": f"Force quit {name}" + (" (it had to be killed)" if method == "kill" else "")}
|
|
|
|
|
|
def main():
|
|
try:
|
|
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
if cmd == "list":
|
|
out = {"running": running()}
|
|
elif cmd == "quit" and len(sys.argv) == 3 and sys.argv[2].isdigit() and len(sys.argv[2]) <= 10:
|
|
out = quit_game(int(sys.argv[2]))
|
|
else:
|
|
raise Fail("usage: list | quit APPID")
|
|
except (Fail, OSError) as e:
|
|
print(json.dumps({"error": str(e)}))
|
|
sys.exit(1)
|
|
except Exception as e: # keep the one-JSON-object contract for the server
|
|
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
|
|
sys.exit(1)
|
|
print(json.dumps(out))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|