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