From f93a5a1850d38bf6be5210ca9e0b8cfad948d82b Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Tue, 28 Jul 2026 22:21:23 +0200 Subject: [PATCH] Support multiple programs per manifest; fix ScummVM launch and audio unlock Replace the single start_cmd manifest field with a "programs" list so a game can expose more than one runnable binary. Duke Nukem 3D's manifests now offer Play and Setup (DOS sound hardware config), and every ScummVM manifest offers Start and Manager (ScummVM's own graphical Launcher). Also fixes two real bugs found along the way: - All 5 ScummVM manifests were passing the game id as `-f `, but -f is ScummVM's --fullscreen flag; ScummVM rejected it as a stray argument and exited before the setup page's poll interval could notice, so clicking Start silently did nothing. Switched to --auto-detect. - The page's audio-unlock listener called ctx.resume() once on first click/keydown and unconditionally removed itself with no .catch(), so a silently failed first attempt left the AudioContext stuck suspended forever with no way to retry. Now retries on every click/keydown until ctx.state actually reports "running". Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz --- CLAUDE.md | 6 ++- TODO.md | 82 ++++++++++++++++++++++++++++++ scripts/setup_server.py | 109 +++++++++++++++++++++++++++++++--------- 3 files changed, 171 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 95d093c..bfac067 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,11 +50,13 @@ There is **no single shared desktop/display for video** — unlike the other noV **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) — the *only* thing `server.sh` starts. No Xvnc/fluxbox/websockify run until a game is actually started. -- Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `.json` manifest living in the `Games\dosbox\` catalog directory on the SMB share, with `name`/`title`/`zip_path`/`release_date`/`publisher`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` under `Games\dosbox\` (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `.json` on the share is enough to make a game appear, no image rebuild needed. `zip_path` is a full path relative to the share root (e.g. `Games/Monkey Island/The Secret of Monkey Island.zip`), **not** assumed to live next to its manifest — manifests always live in the `Games\dosbox\` catalog regardless of where the actual game data sits on the share (the SCUMM games' zips live in their own folders, e.g. `Games\Monkey Island\`, `Games\Indiana Jones\`). `release_date`/`publisher` are static declared metadata (historical facts, can't be derived from the share); install size deliberately is **not** a manifest field — `get_zip_sizes()` looks it up live via a batched `smbclient ls` (one `ls ""` per game in a single connection, results split on each command's `N blocks ... available` trailer) so it can't go stale if a zip is replaced. +- Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `.json` manifest living in the `Games\dosbox\` catalog directory on the SMB share, with `name`/`title`/`zip_path`/`release_date`/`publisher`/`programs` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` under `Games\dosbox\` (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `.json` on the share is enough to make a game appear, no image rebuild needed. `zip_path` is a full path relative to the share root (e.g. `Games/Monkey Island/The Secret of Monkey Island.zip`), **not** assumed to live next to its manifest — manifests always live in the `Games\dosbox\` catalog regardless of where the actual game data sits on the share (the SCUMM games' zips live in their own folders, e.g. `Games\Monkey Island\`, `Games\Indiana Jones\`). `release_date`/`publisher` are static declared metadata (historical facts, can't be derived from the share); install size deliberately is **not** a manifest field — `get_zip_sizes()` looks it up live via a batched `smbclient ls` (one `ls ""` per game in a single connection, results split on each command's `N blocks ... available` trailer) so it can't go stale if a zip is replaced. + +- `programs` is a **list**, not a single command, so a game can expose more than one runnable binary — e.g. `duke`/`dn3d` list both `play` (`duke3d.exe`) and `setup` (`setup.exe`, DOSBox/Build-engine's interactive hardware-configuration wizard, distinct from the game itself). Every manifest on the share has at least one entry (most just have a single `{"id": "start", "label": "Start", ...}`, preserving the plain "Start" button they had before this existed). Each entry gets its own button in the "Installed" row (`doAction('start', name, program_id)`); `POST /start` reads the `program` form field and `start_game()` looks up the matching entry by `id`, using its `cmd` in place of the old single `start_cmd`. Still only one program per game slot at a time — starting a second program while the first is running is refused the same way restarting an already-running game is (`is_running()` doesn't care which program is active, just whether the tracked process is alive). The active program's `label` is stashed in `running_procs[name]["program_label"]` purely for display, so the "Running" status can show e.g. "Running (Setup)" instead of just "Running" now that the game name alone doesn't imply which binary is active. - `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a `"{used}/{MAX_CONCURRENT_GAMES} game slots in use"` line at the top. **None of the action buttons are `
`s** — every one is a bare `' + f'' ) rows = [] @@ -340,7 +382,8 @@ def render_page(host): screen_html = "" volume_html = "" if name in novnc_ports: - status_html = 'Running' + program_label = html.escape(running[name]["program_label"]) + status_html = f'Running ({program_label})' screen_html = f'Open Screen' volume_html = ( f'{html.escape(errors[name])}' - start_button = button("start", name, "Start") if slots_used < MAX_CONCURRENT_GAMES else "" - actions = start_button + button("uninstall", name, "Uninstall") + if slots_used < MAX_CONCURRENT_GAMES: + program_buttons = "".join( + button("start", name, html.escape(p["label"]), p["id"]) + for p in manifest["programs"] + ) + else: + program_buttons = "" + actions = program_buttons + button("uninstall", name, "Uninstall") else: status_html = "Not installed" actions = button("install", name, "Install") @@ -405,19 +454,27 @@ async function refresh() {{ const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); document.getElementById('content').innerHTML = doc.getElementById('content').innerHTML; }} -async function doAction(action, name) {{ +async function doAction(action, name, program) {{ + let body = 'name=' + encodeURIComponent(name); + if (program) body += '&program=' + encodeURIComponent(program); await fetch('/' + action, {{ method: 'POST', headers: {{'Content-Type': 'application/x-www-form-urlencoded'}}, - body: 'name=' + encodeURIComponent(name), + body: body, }}); refresh(); }} setInterval(refresh, 3000); // Audio is on by default - no button. AudioContexts start suspended until a user -// gesture, so this connects everything eagerly and just resumes on the page's first -// click/keypress (whatever the user was already doing, e.g. clicking Start on a game). +// gesture, so this connects everything eagerly and just resumes on every click/keypress +// (whatever the user was already doing, e.g. clicking Start on a game) until it actually +// reports "running" - deliberately NOT a one-shot listener that gives up after the first +// attempt: resume() can fail silently on that very first gesture (browser-dependent, no +// error surfaces without a .catch()), and a one-shot listener that already removed itself +// has no way to retry on the next click, leaving the context stuck suspended forever. +// resume() on an already-running context is a harmless no-op, so retrying on every +// click/keydown is safe. // Safe from the refresh() calls above too, since those never touch anything outside // #content - this script block, and the AudioContext/WebSocket it created, are untouched. const ctx = new AudioContext({{ sampleRate: 48000 }}); @@ -429,9 +486,12 @@ ctx.audioWorklet.addModule('/pcm-worklet.js').then(() => {{ ws.onmessage = (event) => node.port.postMessage(event.data, [event.data]); }}); const unlockAudio = () => {{ - ctx.resume(); - document.removeEventListener('click', unlockAudio); - document.removeEventListener('keydown', unlockAudio); + if (ctx.state === 'running') {{ + document.removeEventListener('click', unlockAudio); + document.removeEventListener('keydown', unlockAudio); + return; + }} + ctx.resume().catch((e) => console.error('AudioContext resume failed', e)); }}; document.addEventListener('click', unlockAudio); document.addEventListener('keydown', unlockAudio); @@ -506,7 +566,8 @@ class Handler(BaseHTTPRequestHandler): 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() + program_id = params.get("program", [""])[0] + threading.Thread(target=start_game, args=(name, manifest, program_id), daemon=True).start() elif action == "stop": threading.Thread(target=stop_game, args=(name,), daemon=True).start()