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 <id>`, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
This commit is contained in:
2026-07-28 22:21:23 +02:00
co-authored by Claude Sonnet 5
parent 953e0615fa
commit f93a5a1850
3 changed files with 171 additions and 26 deletions
+4 -2
View File
@@ -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 `<name>.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 `<name>.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 "<path>"` 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 `<name>.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 `<name>.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 "<path>"` 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 `<form>`s** — every one is a bare `<button onclick="doAction('<action>','<name>')">`; `doAction()` (client-side JS in the page itself) `fetch()`s the action and then calls `refresh()`, which re-`fetch()`es `/` and replaces `#content`'s `innerHTML` in place. The page itself never navigates. This matters a lot — see Audio below for why a real `<form>` submit here was an actual bug, not just a style choice.
- `POST /install`: `smbget -au` (guest auth) the manifest's `zip_path` into a `/tmp/setup-<name>` 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}/<name>` directory exists"; a zip is expected to contain one top-level folder matching its own basename (this happens to already match ScummVM's own game-target IDs for the SCUMM titles, e.g. `monkey2.zip``monkey2/` — convenient, not enforced by the code).
- `POST /uninstall`: `rm -rf ${GAMES_HOME}/<name>`. Refused while the game is running (stop it first) — there's no auto-stop-then-delete.
- **`POST /start`: each game gets its own ephemeral X session, not a shared one.** `_free_display_num()` picks an unused display from `GAME_DISPLAY_NUMS` (`:90`-`:99`, one per `MAX_CONCURRENT_GAMES = 10` slot); if none is free, the attempt is refused and `game_errors[name]` is set to a message shown on that game's row (no slot ever gets allocated for it). Otherwise: clean any stale `/tmp/.X{N}-lock`/`/tmp/.X11-unix/X{N}` for that display (the same reason the other noVNC-family projects do this on startup — a prior Xvnc for that display might not have cleaned up after itself), then start `Xvnc :{N}`, `fluxbox`, and `websockify` (bridging `59{N}``80{N}`, no `-D`/daemonize flag — needs to stay a normal foreground child so its `Popen` handle actually reflects whether it's still alive, unlike the old shared-desktop `server.sh` which didn't care), wait ~1s for Xvnc to bind, then `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/<name>, env={..., "DISPLAY": f":{N}"})`. All four `Popen` handles plus the display/port numbers are tracked together in `running_procs[name]`.
- **`POST /start`: each game gets its own ephemeral X session, not a shared one.** `_free_display_num()` picks an unused display from `GAME_DISPLAY_NUMS` (`:90`-`:99`, one per `MAX_CONCURRENT_GAMES = 10` slot); if none is free, the attempt is refused and `game_errors[name]` is set to a message shown on that game's row (no slot ever gets allocated for it). Otherwise: clean any stale `/tmp/.X{N}-lock`/`/tmp/.X11-unix/X{N}` for that display (the same reason the other noVNC-family projects do this on startup — a prior Xvnc for that display might not have cleaned up after itself), then start `Xvnc :{N}`, `fluxbox`, and `websockify` (bridging `59{N}``80{N}`, no `-D`/daemonize flag — needs to stay a normal foreground child so its `Popen` handle actually reflects whether it's still alive, unlike the old shared-desktop `server.sh` which didn't care), wait ~1s for Xvnc to bind, then `subprocess.Popen(program["cmd"], cwd=GAMES_HOME/<name>, env={..., "DISPLAY": f":{N}"})` where `program` is the manifest's `programs` entry matching the `program_id` the client sent. All four `Popen` handles plus the display/port numbers are tracked together in `running_procs[name]`.
- `POST /stop`: tears down all four processes for that game (game first, then websockify/fluxbox/Xvnc), each `terminate()`d then escalated to `kill()` after a 5s grace period, and cleans up the stale-lock files for that display.
- `is_running(name)` does the same full teardown **lazily** if the game process exited on its own (quit or crash) — it won't leave an idle Xvnc/websockify pair holding a slot forever just because nobody clicked Stop. `render_page()` calls this for every tracked game on every load, so the slot count and each row's status stay accurate without polling.
- Each running game's row shows its own "Open Screen" link (only while `Running`) — a plain top-level link straight to that game's own noVNC URL (`https://<host>:<novnc_port>/`, opens in a new tab). Deliberately **not** an iframe — see Audio below for why.
+82
View File
@@ -42,6 +42,27 @@
- `indy3` — Indiana Jones and the Last Crusade
- `tentacle` — Day of the Tentacle
**Real bug found later (all 5 of the above), reported as "Indiana Jones and the Fate of
Atlantis doesn't start properly"**: every one of these manifests' command was
`["/usr/games/scummvm", "-p", ".", "-f", "atlantis"]`-shaped, i.e. `-f <target>`. `-f` is
ScummVM's `--fullscreen` flag, not "select this game" — there's no flag that takes a bare
game-id positionally either; passing one is rejected outright ("Stray argument 'atlantis'"),
and ScummVM exits near-instantly with a nonzero status. Because the exit happens faster than
the setup page's ~3s poll interval, `is_running()`'s lazy cleanup silently reaped it before
anyone saw a "Running" status or an error message — clicking Start just looked like nothing
happened, no error surfaced anywhere. (This also means the "verified end-to-end" claim above,
from when these were first added, was wrong — Stop/Uninstall working afterward isn't
evidence the game itself ran; it only proves `is_running()` correctly saw it wasn't.)
Root-caused via `docker exec` running the exact command by hand and reading ScummVM's own
stderr, rather than through the setup page. Fixed by switching every one of these 5
manifests' command to `["/usr/games/scummvm", "-p", ".", "--auto-detect"]` — ScummVM's own
flag for "scan the given path and start whatever single game it finds there", rather than
fighting its target/config-file model, which fits this container's one-game-per-directory
layout exactly. Verified for real this time: manually launched under a live Xvnc, screenshotted,
and confirmed the actual Indiana Jones and the Fate of Atlantis title screen renders (not
just "process didn't crash") - then re-verified through the real setup page end-to-end
(`POST /start` -> "Running (Start)" -> `POST /stop`).
Skipped the German CD release of Day of the Tentacle on the share (`Day Of The Tentacle
(CD DOS, German).zip`) — unlike every other zip here, it doesn't wrap its contents in a
single top-level folder (loose files at the zip root plus a stray `MANIAC/` dir for the
@@ -199,3 +220,64 @@
sound TODO item above. Left as a known gap for these two specifically; fixing it would mean
either reverse-engineering `duke3d.cfg`'s binary format to patch the stored IRQ/DMA, or
finding a way to script `SETUP.EXE`'s interactive hardware wizard.
- **Redesigned the manifest schema so a game can expose more than one runnable binary**,
replacing the single `start_cmd` field with a `programs` list of `{id, label, cmd}` (see
`setup_server.py`'s module docstring for the full schema). Direct motivation: the
`duke`/`dn3d` audio gap above — rather than trying to script or reverse-engineer
`SETUP.EXE`'s hardware wizard from outside, just expose `SETUP.EXE` itself as a second
"program" alongside the game, so the interactive fix is a button click away. Both
`duke.json`/`dn3d.json` now list `play` (`duke3d.exe`) and `setup` (`setup.exe`); every
other existing manifest on the share (`stuntcar`, `monkey`, `monkey2`, `atlantis`, `indy3`,
`tentacle`) was migrated to a single-entry `programs: [{"id": "start", "label": "Start", ...}]`
so the "Installed" row still shows one plain "Start" button for them, unchanged in practice.
`start_game()` now takes a `program_id` and looks up the matching entry; only one program per
game can run at a time (same one-slot-per-game model as before — starting a second program
while the first is still running is refused the same way restarting an already-running game
is). The "Running" status now shows which program is active (e.g. "Running (Setup)") since
that's no longer implied by the game name alone. Verified end-to-end: built the image,
installed `duke`, POSTed `/start` with `program=setup`, confirmed via `ps aux` inside the
container that `dosbox setup.exe` (not `duke3d.exe`) was the process actually running, and
via a real screenshot that DOSBox's actual "Choose Sound FX Card" screen renders — i.e. the
interactive fix path this was built for is genuinely reachable now. Did not go further and
actually reconfigure the sound card/re-verify audio afterward — that's still a manual,
per-game step left to whoever plays `duke`/`dn3d`, not something the container should do
unattended.
- **Real bug found via user report ("no audio, no audio icon in browser")**: the page's
audio-unlock listener (`unlockAudio()`) called `ctx.resume()` once on the page's first
click/keydown and then unconditionally removed itself, with no `.catch()` on the
`resume()` promise. Confirmed the server-side pipeline was fine the whole time (PulseAudio
sink-input active and uncorked, `parec` capturing real non-silent PCM, the raw WebSocket
bridge on `AUDIO_PORT` handshaking and streaming genuine non-zero frames when tested
directly) — the browser's own `ctx.state` stayed `"suspended"` no matter how many times the
user clicked the page. Root cause: if that very first resume attempt silently failed for any
reason (rejected promise with no handler = no console error, easy to miss), the listener had
already removed itself and there was no way left to ever retry — permanently stuck
suspended for the rest of that page load. Confirmed directly: manually attaching a fresh
one-off click listener that called `ctx.resume()` succeeded immediately (`state=running`),
proving the `AudioContext` itself was fine and only the one-shot unlock logic was broken.
Fixed by making `unlockAudio()` idempotent and non-removing: it now calls `ctx.resume()`
(with a `.catch()` that logs any failure) on *every* click/keydown, and only detaches the
listeners once `ctx.state` has actually become `"running"` — safe to call repeatedly since
resuming an already-running context is a no-op. Verified fixed by the user after rebuilding
and restarting the container: audio icon now appears and sound plays.
- **Added a "Manager" program to all 5 ScummVM manifests** (`monkey`, `monkey2`, `atlantis`,
`indy3`, `tentacle`), alongside their existing "Start" (`--auto-detect`) - the SCUMM
equivalent of Duke's "Setup" button. Plain `scummvm -p .` with no game argument opens
ScummVM's own graphical Launcher (game list, Game Options, Global Options, MT-32/subtitle/
audio-driver settings, etc.) instead of jumping straight into the game - but confirmed via
screenshot that `-p .` alone shows an *empty* list ("None" selected, nothing in the game
panel); the game has to be registered first via `--add` (a separate one-shot command that
exits after adding, confirmed idempotent - running it again just logs "already been added,
skipping" and doesn't error or duplicate the config entry) before the Launcher will show it
pre-selected and ready for "Game Options...". So the "manager" program's command is
`["bash", "-c", "/usr/games/scummvm -p . --add >/tmp/scummvm-add.log 2>&1; exec /usr/games/scummvm -p ."]`
- `--add` first (idempotent, safe on every launch), then `exec`'d into the real interactive
`scummvm -p .` process so the tracked PID (and therefore `_teardown()`'s terminate/kill) is
the actual ScummVM process, not a wrapper shell with an orphaned child. Verified end-to-end
through the real setup page: `POST /start` with `program=manager` -> "Running (Manager)" ->
confirmed via `ps aux` that the real `scummvm -p .` process (not the wrapper) is what's
running -> screenshot confirms the Launcher opens with "Indiana Jones and the Fate of
Atlantis" already listed and selectable.
+85 -24
View File
@@ -16,13 +16,49 @@ Manifest schema (all fields required):
# next to their manifest
"release_date": "1989",
"publisher": "MicroStyle",
"start_cmd": ["dosbox", "run.bat", "-conf", "dosbox-0.74-3.conf"]
"programs": [
{"id": "start", "label": "Start", "cmd": ["dosbox", "run.bat", "-conf", "dosbox-0.74-3.conf"]}
]
}
Uninstall is implicit (remove GAMES_HOME/<name>); stop is implicit (terminate
the process tracked from the last start_cmd). Manifests themselves always live
in SMB_DIR (the catalog directory), even though the zip_path they point at may
not. Install size isn't a manifest field - it's looked up live from the share
(via `smbclient ls`) so it can't go stale.
"programs" is a list, not a single command, because some games need more than
one runnable binary - e.g. Duke Nukem 3D ships both the game itself and a
separate SETUP.EXE for interactively picking DOS sound hardware, and the two
aren't interchangeable:
{
"programs": [
{"id": "play", "label": "Play", "cmd": ["dosbox", "duke3d.exe"]},
{"id": "setup", "label": "Setup", "cmd": ["dosbox", "setup.exe"]}
]
}
The 5 ScummVM manifests use the same pattern for the SCUMM equivalent of a
hardware-setup binary - ScummVM's own graphical Launcher (Game Options,
Global Options, audio driver, MT-32 emulation, etc.), reached by a "manager"
program instead of a "setup" one:
{
"programs": [
{"id": "start", "label": "Start", "cmd": ["/usr/games/scummvm", "-p", ".", "--auto-detect"]},
{"id": "manager", "label": "Manager", "cmd": ["bash", "-c",
"/usr/games/scummvm -p . --add >/tmp/scummvm-add.log 2>&1; exec /usr/games/scummvm -p ."]}
]
}
Plain `scummvm -p .` with no game argument opens the Launcher, but its game
list is empty until the game has been registered with `--add` (a separate
one-shot command, confirmed idempotent - safe to run before every Manager
launch); the `exec` at the end keeps the tracked process the real interactive
`scummvm` instance rather than the wrapper shell, so stop/teardown targets
the right PID.
Each entry gets its own button in the "Installed" row; whichever one is
clicked becomes the process tracked for that game's single slot (only one
program per game can run at a time - starting a second one while the first is
still running is refused the same way starting an already-running game is).
"id" is what's sent back over the wire (POST start, name=<game>&program=<id>);
"label" is only for display. Uninstall is implicit (remove GAMES_HOME/<name>);
stop is implicit (terminate whichever program's process is currently
tracked). Manifests themselves always live in SMB_DIR (the catalog
directory), even though the zip_path they point at may not. Install size
isn't a manifest field - it's looked up live from the share (via `smbclient
ls`) so it can't go stale.
Each running game gets its own ephemeral X session (Xvnc + fluxbox +
websockify on their own display/ports) instead of sharing one screen, so up
@@ -89,7 +125,7 @@ GAME_DISPLAY_NUMS = list(range(90, 90 + MAX_CONCURRENT_GAMES)) # :90..:99, one
state_lock = threading.RLock()
install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..."
game_errors = {} # name -> error message from the last failed start attempt
running_procs = {} # name -> {game, xvnc, fluxbox, websockify: Popen, display_num, novnc_port}
running_procs = {} # name -> {game, xvnc, fluxbox, websockify: Popen, display_num, novnc_port, program_label}
def smb_run(*commands):
@@ -228,10 +264,14 @@ def uninstall_game(name):
install_state.pop(name, None)
def start_game(name, manifest):
def start_game(name, manifest, program_id):
with state_lock:
if is_running(name):
return
program = next((p for p in manifest["programs"] if p["id"] == program_id), None)
if program is None:
game_errors[name] = f"unknown program {program_id!r}"
return
display_num = _free_display_num()
if display_num is None:
game_errors[name] = f"no free game slot ({MAX_CONCURRENT_GAMES}/{MAX_CONCURRENT_GAMES} running)"
@@ -258,11 +298,11 @@ def start_game(name, manifest):
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
time.sleep(1) # give Xvnc a moment to bind before the game connects
game_proc = subprocess.Popen(manifest["start_cmd"], cwd=os.path.join(GAMES_HOME, name), env=game_env)
game_proc = subprocess.Popen(program["cmd"], cwd=os.path.join(GAMES_HOME, name), env=game_env)
running_procs[name] = {
"game": game_proc, "xvnc": xvnc, "fluxbox": fluxbox, "websockify": websockify,
"display_num": display_num, "novnc_port": novnc_port,
"display_num": display_num, "novnc_port": novnc_port, "program_label": program["label"],
}
@@ -318,15 +358,17 @@ def render_page(host):
errors = dict(game_errors)
for name in list(running_procs):
is_running(name) # lazy cleanup of any game that exited on its own
novnc_ports = {name: info["novnc_port"] for name, info in running_procs.items()}
running = dict(running_procs)
novnc_ports = {name: info["novnc_port"] for name, info in running.items()}
slots_used = len(novnc_ports)
def button(action, name, label):
def button(action, name, label, program=None):
# Not a <form> - a real form submit navigates the page, which would tear down the
# persistent audio AudioContext/WebSocket below. doAction() fetch()es instead and
# re-renders just #content in place, so the page never actually navigates.
program_arg = f",'{program}'" if program else ""
return (
f'<button onclick="doAction(\'{action}\',\'{name}\')">{label}</button>'
f'<button onclick="doAction(\'{action}\',\'{name}\'{program_arg})">{label}</button>'
)
rows = []
@@ -340,7 +382,8 @@ def render_page(host):
screen_html = ""
volume_html = ""
if name in novnc_ports:
status_html = '<span class="status busy">Running</span>'
program_label = html.escape(running[name]["program_label"])
status_html = f'<span class="status busy">Running ({program_label})</span>'
screen_html = f'<a href="https://{host}:{novnc_ports[name]}/" target="_blank">Open Screen</a>'
volume_html = (
f'<input type="range" min="0" max="150" value="100" class="volume" '
@@ -359,8 +402,14 @@ def render_page(host):
status_html = '<span class="status ok">Installed</span>'
if name in errors:
status_html += f'<br><span class="status err">{html.escape(errors[name])}</span>'
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()