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