From 827f03f087f7a229e4730730f57ac75daf510568 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Tue, 28 Jul 2026 10:27:33 +0200 Subject: [PATCH] Drive games from per-game JSON manifests instead of raw zip scanning Games are no longer discovered from *.zip files on the SMB share; each game now has its own .json manifest (name/title/zip/start_cmd) living next to its zip, discovered dynamically via smbclient. Nothing about a game's identity or how to install/start it is hardcoded in the image anymore. The setup page also grew Start/Stop/Uninstall buttons, backed by real process tracking (Popen + terminate/kill). Piloted on stuntcar only (manifest already uploaded to the share); t7g has no manifest yet so it won't appear until one's added. The old game.sh/start_game.sh docker-exec launch path is left as-is for now and overlaps with the new Start button - noted in TODO.md for later cleanup. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz --- CLAUDE.md | 18 ++-- TODO.md | 13 +++ scripts/setup_server.py | 200 +++++++++++++++++++++++++++++----------- 3 files changed, 171 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 42d66a0..349d61a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,14 +45,20 @@ There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server. `run.sh` hardcodes `DISPLAY_NUM=99` (implicitly, via the image default) and the `7099` port mapping; update it if `DISPLAY_NUM` ever changes. Note `run.sh` does *not* currently publish the `59xx`/`80xx` noVNC ports to the host — only `7099` (setup) is mapped, plus whatever the container's on the same Docker network can reach directly. -**Game installation** (`scripts/setup_server.py`), the key thing that differs from the other noVNC-family projects: +**Game installation & lifecycle** (`scripts/setup_server.py`), the key thing that differs from the other noVNC-family projects: - A stdlib-only Python `ThreadingHTTPServer` (no third-party deps, no pip installs) started in the background by `server.sh` alongside Xvnc/fluxbox/websockify. -- `GET /` renders an HTML page listing every `*.zip` found live on the SMB share (via `smbclient -N ... -c 'ls Games\dosbox\*.zip'`, parsed with a regex — not hardcoded, so new zips dropped on the share show up automatically) alongside each game's install status, sourced by checking for a non-empty `${GAMES_HOME}/` directory. -- `POST /install` (form-submitted, `name=`) validates the name against the live SMB listing, then kicks off `install_game()` in a background daemon thread: `smbget -au` (guest auth) into a `/tmp/setup-` scratch dir, then `unzip -o -d ${GAMES_HOME}`. Per-game state (`downloading`/`extracting`/`done`/`error: ...`) lives in an in-process dict guarded by a lock; the page does a `` every 3s while anything is in flight, no client-side JS. -- `smbget -a` (guest) cannot be combined with `-o` (output-file) — they conflict on the underlying `-U` option — so the download step `cd`s into the scratch dir and lets `smbget` save under its default filename instead. -- Games are recognized purely by directory name under `${GAMES_HOME}`; there's no separate manifest. A zip is expected to contain a single top-level folder matching its own basename (this holds for both `stuntcar.zip`→`stuntcar/` and `t7g.zip`→`t7g/`). +- Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `.json` manifest living next to its zip on the SMB share (e.g. `Games\dosbox\stuntcar.json`), with `name`/`title`/`zip`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` on the share (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `.json` + `.zip` pair on the share is enough to make a game appear, no image rebuild needed. +- `GET /` renders one row per discovered manifest with its live status (Not installed / downloading / extracting / Installed / Running / error) and the matching action buttons. +- `POST /install`: `smbget -au` (guest auth) the manifest's `zip` into a `/tmp/setup-` scratch dir, then `unzip -o -d ${GAMES_HOME}`. `smbget -a` (guest) cannot be combined with `-o` (output-file) — they conflict on the underlying `-U` option — so the download `cd`s into the scratch dir and lets `smbget` save under its default filename instead. Installed-ness is just "non-empty `${GAMES_HOME}/` directory exists"; a zip is expected to contain one top-level folder matching its own basename. +- `POST /uninstall`: `rm -rf ${GAMES_HOME}/`. Refused while the game is running (stop it first) — there's no auto-stop-then-delete. +- `POST /start`: `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/)`, tracked in an in-process dict keyed by game name (`running_procs`). This inherits `setup_server.py`'s own environment, including `$DISPLAY`, so the launched process renders onto the same Xvnc display fluxbox/noVNC are already serving — no separate X setup needed. +- `POST /stop`: `terminate()`s the tracked process, escalating to `kill()` after a 5s grace period. +- Per-game install progress (`install_state`) and the running-process table (`running_procs`) are both guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page does a `` every 3s while any install is in flight; no client-side JS anywhere. +- **Only `stuntcar.json` exists on the share right now** — this whole manifest/start/stop/uninstall mechanism was rolled out as a pilot on that one (small, fast-to-test) game before being extended to `t7g`. Until `t7g.json` is added, `t7g` simply won't appear on the setup page even though its zip is still there. -**Playing a game**: `game.sh ` is `docker exec -it dosbox-novnc start_game.sh ` — it runs *inside* the already-running container (started via `run.sh`), attaching to the same `$DISPLAY` that Xvnc/fluxbox/noVNC are already serving. `scripts/start_game.sh` just runs `dosbox ${GAMES_HOME}//run.bat -conf ${GAMES_HOME}//dosbox-0.74-3.conf` — no signal handling of its own, since it's not PID 1 and Docker doesn't manage its lifecycle directly. +**Playing a game — two overlapping paths right now**: +1. The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game. +2. `game.sh ` → `docker exec -it dosbox-novnc start_game.sh `, running *inside* the already-running container. `scripts/start_game.sh` hardcodes `DOSBOX_VERSION="0.74-3"` and the `run.bat`/`dosbox-.conf` convention — this predates the manifest system and was deliberately left as-is during the pilot. The two paths don't know about each other (e.g. a game started via `game.sh` won't show as "Running" on the setup page). Worth reconciling once manifests cover every game — see `TODO.md`. **Signal handling** (`scripts/server_start.sh` + `scripts/signals.sh`): identical pattern to the other noVNC-family projects — Docker sends `SIGTERM` to PID 1 on `docker stop`, `server_start.sh` traps it, forwards it to the child process tree, waits for the descendants to exit, and re-raises `128 + signal number` as its own exit code. Generic (`$APP` passed as an argument), not specific to `server.sh`. diff --git a/TODO.md b/TODO.md index 594b2fa..9322c8d 100644 --- a/TODO.md +++ b/TODO.md @@ -13,3 +13,16 @@ an HTML page on `${SETUP_PORT}` (`70${DISPLAY_NUM}`, published as `7099` by `run.sh`) listing every `*.zip` on the SMB share with an Installed/Install status per game; the user clicks "Install" to fetch+extract a specific game into `${GAMES_HOME}` on demand. + +- **Manifest-driven games + Start/Stop/Uninstall (test balloon, `stuntcar` only)**: games are + no longer discovered from raw `*.zip` files on the share. `setup_server.py` now lists + `*.json` manifests instead (one per game, e.g. `Games\dosbox\stuntcar.json`), each + declaring `name`/`title`/`zip`/`start_cmd`. The web page grew Start/Stop/Uninstall buttons + alongside Install, driven entirely by that manifest — nothing about a game's identity or + how to run it is hardcoded in the image anymore. Only `stuntcar.json` exists on the share + so far (as a deliberate pilot); `t7g` has no manifest yet and will not show up in the setup + page until one is added (`t7g.json` with a `start_cmd` for its `dosbox-0.74-3.conf`). + `game.sh`/`scripts/start_game.sh` (the old `docker exec`-based launch path, with its + hardcoded `DOSBOX_VERSION`/`run.bat` convention) were intentionally left untouched for this + pilot and now overlap with the new Start button — worth reconciling (or removing) once the + manifest approach is rolled out to all games. diff --git a/scripts/setup_server.py b/scripts/setup_server.py index 7b10587..2186e00 100755 --- a/scripts/setup_server.py +++ b/scripts/setup_server.py @@ -1,9 +1,25 @@ #!/usr/bin/env python3 -"""HTML setup routine: lets the user browse games available on the SMB -share and install them into GAMES_HOME on demand, instead of the image -auto-downloading everything at container startup.""" +"""HTML setup routine for DOSBox games. + +Nothing about which games exist is hardcoded here: available games are +discovered by listing *.json manifests on the SMB share, one per game, +each describing how to install/uninstall/start/stop that game. Drop a new +.json (and matching zip) on the share and it shows up here with no +image changes needed. + +Manifest schema (all fields required): +{ + "name": "stuntcar", # must match the manifest's own filename + "title": "Stuntcar Racer", + "zip": "stuntcar.zip", # filename under SMB_DIR to fetch on install + "start_cmd": ["dosbox", "run.bat", "-conf", "dosbox-0.74-3.conf"] +} +Uninstall is implicit (remove GAMES_HOME/); stop is implicit (terminate +the process tracked from the last start_cmd). +""" import html +import json import os import re import subprocess @@ -17,33 +33,60 @@ SMB_DIR = r"Games\dosbox" GAMES_HOME = os.environ["GAMES_HOME"] SETUP_PORT = int(os.environ.get("SETUP_PORT", "8080")) -state_lock = threading.Lock() -install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..." +state_lock = threading.RLock() +install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..." +running_procs = {} # name -> subprocess.Popen -def list_remote_games(): +def smb_run(*commands): result = subprocess.run( - ["smbclient", "-N", f"//{SMB_SERVER}/{SMB_SHARE}", "-c", f"ls {SMB_DIR}\\*.zip"], + ["smbclient", "-N", f"//{SMB_SERVER}/{SMB_SHARE}", "-c", ";".join(commands)], capture_output=True, text=True, timeout=15, ) - games = [] - for line in result.stdout.splitlines(): - m = re.match(r"\s*(\S+)\.zip\s+A\s+(\d+)", line) - if m: - games.append((m.group(1), int(m.group(2)))) - return sorted(games) + return result.stdout -def list_installed_games(): - if not os.path.isdir(GAMES_HOME): - return set() - return { - name for name in os.listdir(GAMES_HOME) - if os.path.isdir(os.path.join(GAMES_HOME, name)) and os.listdir(os.path.join(GAMES_HOME, name)) - } +def discover_games(): + """Returns {name: manifest} for every *.json manifest found on the share.""" + listing = smb_run(f"ls {SMB_DIR}\\*.json") + names = [m.group(1) for m in re.finditer(r"\s*(\S+)\.json\s+A\s+\d+", listing)] + if not names: + return {} + + tmpdir = "/tmp/manifests" + os.makedirs(tmpdir, exist_ok=True) + gets = [f"get {SMB_DIR}\\{name}.json {tmpdir}/{name}.json" for name in names] + smb_run(*gets) + + games = {} + for name in names: + try: + with open(f"{tmpdir}/{name}.json") as f: + manifest = json.load(f) + if manifest.get("name") == name: + games[name] = manifest + except (OSError, json.JSONDecodeError): + continue + return games -def install_game(name): +def is_installed(name): + path = os.path.join(GAMES_HOME, name) + return os.path.isdir(path) and os.listdir(path) + + +def is_running(name): + with state_lock: + proc = running_procs.get(name) + if proc is None: + return False + if proc.poll() is None: + return True + del running_procs[name] + return False + + +def install_game(name, manifest): with state_lock: if install_state.get(name) in ("downloading", "extracting"): return @@ -52,11 +95,12 @@ def install_game(name): tmpdir = f"/tmp/setup-{name}" try: os.makedirs(tmpdir, exist_ok=True) - zip_path = os.path.join(tmpdir, f"{name}.zip") + zip_name = manifest["zip"] + zip_path = os.path.join(tmpdir, zip_name) # smbget's -a (guest) can't be combined with -o (output file), so let it save - # under the default name in tmpdir instead, same as the original build-time fetch. + # under its default name in tmpdir instead. subprocess.run( - ["smbget", "-au", f"smb://{SMB_SERVER}/{SMB_SHARE}/Games/dosbox/{name}.zip"], + ["smbget", "-au", f"smb://{SMB_SERVER}/{SMB_SHARE}/Games/dosbox/{zip_name}"], check=True, timeout=1800, cwd=tmpdir, ) with state_lock: @@ -72,37 +116,73 @@ def install_game(name): subprocess.run(["rm", "-rf", tmpdir], check=False) +def uninstall_game(name): + with state_lock: + if is_running(name): + return + subprocess.run(["rm", "-rf", os.path.join(GAMES_HOME, name)], check=False) + install_state.pop(name, None) + + +def start_game(name, manifest): + with state_lock: + if is_running(name): + return + proc = subprocess.Popen(manifest["start_cmd"], cwd=os.path.join(GAMES_HOME, name)) + running_procs[name] = proc + + +def stop_game(name): + with state_lock: + proc = running_procs.get(name) + if proc is None: + return + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + with state_lock: + running_procs.pop(name, None) + + def render_page(): - remote = list_remote_games() - installed = list_installed_games() + games = discover_games() with state_lock: pending = dict(install_state) any_pending = any(v in ("downloading", "extracting") for v in pending.values()) refresh_tag = '' if any_pending else "" - rows = [] - for name, size in remote: - status = pending.get(name) - if name in installed and status not in ("downloading", "extracting"): - action = 'Installed' - elif status in ("downloading", "extracting"): - action = f'{status}...' - elif status and status.startswith("error"): - action = ( - f'{html.escape(status)}' - f'
' - f'
' - ) - else: - action = ( - f'
' - f'
' - ) - rows.append( - f"{html.escape(name)}{size / 1_000_000:.1f} MB{action}" + def button(action, name, label): + return ( + f'
' + f'' + f'
' ) + rows = [] + for name, manifest in sorted(games.items()): + title = html.escape(manifest.get("title", name)) + status = pending.get(name) + if is_running(name): + status_html = 'Running' + actions = button("stop", name, "Stop") + elif status in ("downloading", "extracting"): + status_html = f'{status}...' + actions = "" + elif status and status.startswith("error"): + status_html = f'{html.escape(status)}' + actions = button("install", name, "Retry") + elif is_installed(name): + status_html = 'Installed' + actions = button("start", name, "Start") + button("uninstall", name, "Uninstall") + else: + status_html = "Not installed" + actions = button("install", name, "Install") + rows.append(f"{title}{status_html}{actions}") + return f""" @@ -112,8 +192,8 @@ def render_page(): body {{ font-family: sans-serif; margin: 2em; }} table {{ border-collapse: collapse; width: 100%; max-width: 640px; }} th, td {{ text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #ccc; }} -form {{ margin: 0; }} -button {{ padding: 0.3em 0.8em; }} +form {{ margin: 0; display: inline; }} +button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }} .status.ok {{ color: #2a2; font-weight: bold; }} .status.busy {{ color: #a70; font-weight: bold; }} .status.err {{ color: #c22; }} @@ -122,8 +202,8 @@ button {{ padding: 0.3em 0.8em; }}

DOSBox games

- -{''.join(rows) if rows else ''} + +{''.join(rows) if rows else ''}
GameSizeStatus / Action
No games found on the share
GameStatusActions
No game manifests found on the share
""" @@ -148,15 +228,27 @@ class Handler(BaseHTTPRequestHandler): self._send_html("Not found", status=404) def do_POST(self): - if urlparse(self.path).path != "/install": + action = urlparse(self.path).path.lstrip("/") + if action not in ("install", "uninstall", "start", "stop"): self._send_html("Not found", status=404) return + length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length).decode("utf-8") name = parse_qs(body).get("name", [""])[0] - valid_names = {n for n, _ in list_remote_games()} - if name in valid_names: - threading.Thread(target=install_game, args=(name,), daemon=True).start() + games = discover_games() + + if name in games: + manifest = games[name] + if action == "install": + threading.Thread(target=install_game, args=(name, manifest), daemon=True).start() + elif action == "uninstall": + threading.Thread(target=uninstall_game, args=(name,), daemon=True).start() + elif action == "start": + threading.Thread(target=start_game, args=(name, manifest), daemon=True).start() + elif action == "stop": + threading.Thread(target=stop_game, args=(name,), daemon=True).start() + self.send_response(303) self.send_header("Location", "/") self.end_headers()