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:
+85
-24
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user