Files
docker-dosbox-novnc/scripts/setup_server.py
T
jensandClaude Sonnet 5 301503a276 Get game audio into the browser via PulseAudio + a WebSocket bridge
VNC/noVNC only ever streams video, so game sound needed a completely
separate path. Adds pulseaudio/pulseaudio-utils/libasound2-plugins and
routes ALSA's default device through Pulse (/etc/asound.conf), so
dosbox/scummvm need zero special config. server.sh starts one
PulseAudio daemon and one pcm_ws_bridge.py (from docker-common) for the
container's whole lifetime, capturing Pulse's single default sink via
parec.

Audio is a single shared mix, not per-game - considered and dropped a
per-slot-isolated design (mirroring the video architecture) as
unnecessary complexity per direction. Every running game's audio just
mixes into the one default sink; every /screen/<name> page connects to
the same AUDIO_PORT.

setup_server.py gains GET /screen/<name> (an iframe onto the game's
noVNC screen plus an Enable Sound button - browsers require a user
gesture before audio can start) and GET /pcm-worklet.js. The "Open
Screen" link simplifies from a client-side-JS-built cross-port link to
a plain same-origin relative link, since /screen/<name> now reads the
real host server-side from the request's own Host header.

Found and fixed a real bug along the way: parec --device=@DEFAULT_SINK@.monitor
looks correct but fails with "Stream error: Invalid argument" - the
actual PulseAudio macro is the single token @DEFAULT_MONITOR@.

Verified end-to-end with real audio, not just plumbing: confirmed via
`pactl list sink-inputs` that dosbox connects to Pulse correctly
(unmuted, uncorked), then used xdotool to advance stuntcar past its
silent title screen and captured real audible game audio (RMS ~9292)
through the WebSocket bridge with a raw Python client.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
2026-07-28 16:27:10 +02:00

442 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""HTML setup routine for DOSBox/ScummVM games.
Nothing about which games exist is hardcoded here: available games are
discovered by listing *.json manifests on the SMB share, one per game,
each describing how to install/uninstall/start/stop that game. Drop a new
<name>.json (and matching zip) on the share and it shows up here with no
image changes needed.
Manifest schema (all fields required):
{
"name": "stuntcar", # must match the manifest's own filename
"title": "Stuntcar Racer",
"zip_path": "Games/dosbox/stuntcar.zip", # path to the zip, relative to the share root -
# games can live anywhere on the share, not just
# next to their manifest
"release_date": "1989",
"publisher": "MicroStyle",
"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.
Each running game gets its own ephemeral X session (Xvnc + fluxbox +
websockify on their own display/ports) instead of sharing one screen, so up
to MAX_CONCURRENT_GAMES can run side by side without fighting over focus or
being invisible to each other. Starting past that cap is refused.
Audio is deliberately *not* per-game: server.sh starts one PulseAudio daemon
and one pcm_ws_bridge.py (from docker-common) for the whole container's
lifetime, capturing PulseAudio's single default sink. Every running game's
audio mixes together there (normal PulseAudio behavior, no per-game routing
needed), and every /screen/<name> page connects to that same shared
AUDIO_PORT - simpler than per-slot audio isolation, at the cost of not being
able to tell games' audio apart when more than one is running.
"""
import html
import json
import os
import re
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
SMB_SERVER = "vlda-01"
SMB_SHARE = "software"
SMB_DIR = r"Games\dosbox"
GAMES_HOME = os.environ["GAMES_HOME"]
SETUP_PORT = int(os.environ.get("SETUP_PORT", "8080"))
AUDIO_PORT = int(os.environ.get("AUDIO_PORT", "8081"))
SCREEN_W = os.environ.get("SCREEN_W", "1024")
SCREEN_H = os.environ.get("SCREEN_H", "768")
NOVNC_CERT = "/tmp/novnc.pem"
NOVNC_KEY = "/tmp/novnc.key"
SCRIPTS_HOME = os.environ.get("SCRIPTS_HOME", "/opt/scripts")
MAX_CONCURRENT_GAMES = 10
GAME_DISPLAY_NUMS = list(range(90, 90 + MAX_CONCURRENT_GAMES)) # :90..:99, one per game slot
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}
def smb_run(*commands):
result = subprocess.run(
["smbclient", "-N", f"//{SMB_SERVER}/{SMB_SHARE}", "-c", ";".join(commands)],
capture_output=True, text=True, timeout=15,
)
return result.stdout
def discover_games():
"""Returns {name: manifest} for every *.json manifest found on the share."""
listing = smb_run(f"ls {SMB_DIR}\\*.json")
names = [m.group(1) for m in re.finditer(r"\s*(\S+)\.json\s+A\s+\d+", listing)]
if not names:
return {}
tmpdir = "/tmp/manifests"
os.makedirs(tmpdir, exist_ok=True)
gets = [f"get {SMB_DIR}\\{name}.json {tmpdir}/{name}.json" for name in names]
smb_run(*gets)
games = {}
for name in names:
try:
with open(f"{tmpdir}/{name}.json") as f:
manifest = json.load(f)
if manifest.get("name") == name:
games[name] = manifest
except (OSError, json.JSONDecodeError):
continue
return games
def get_zip_sizes(games):
"""Returns {name: size_bytes or None}, looked up live via smbclient so it can't go stale."""
if not games:
return {}
names = list(games)
commands = [f'ls "{games[name]["zip_path"].replace("/", chr(92))}"' for name in names]
output = smb_run(*commands)
# Each `ls` result ends with a "N blocks of size 1024. M blocks available" trailer,
# so splitting on that reliably separates one command's output from the next.
chunks = re.split(r"\n\s*\d+ blocks of size \d+\.\s*\d+ blocks available\s*\n?", output)
sizes = {}
for name, chunk in zip(names, chunks):
m = re.search(r"\s+A\s+(\d+)\s", chunk)
sizes[name] = int(m.group(1)) if m else None
return sizes
def is_installed(name):
path = os.path.join(GAMES_HOME, name)
return os.path.isdir(path) and os.listdir(path)
def _free_display_num():
used = {info["display_num"] for info in running_procs.values()}
for d in GAME_DISPLAY_NUMS:
if d not in used:
return d
return None
def _clean_display(display_num):
subprocess.run(
["rm", "-f", f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"],
check=False,
)
def _teardown(info):
for key in ("game", "websockify", "fluxbox", "xvnc"):
proc = info[key]
if proc.poll() is None:
proc.terminate()
for key in ("game", "websockify", "fluxbox", "xvnc"):
proc = info[key]
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
_clean_display(info["display_num"])
def is_running(name):
with state_lock:
info = running_procs.get(name)
if info is None:
return False
if info["game"].poll() is None:
return True
# The game exited on its own (quit/crash) - tear down its X session too
# instead of leaving an idle Xvnc/websockify pair holding the slot.
_teardown(info)
del running_procs[name]
return False
def install_game(name, manifest):
with state_lock:
if install_state.get(name) in ("downloading", "extracting"):
return
install_state[name] = "downloading"
tmpdir = f"/tmp/setup-{name}"
try:
os.makedirs(tmpdir, exist_ok=True)
zip_name = manifest["zip_path"].rsplit("/", 1)[-1]
zip_path = os.path.join(tmpdir, zip_name)
# smbget's -a (guest) can't be combined with -o (output file), so let it save
# under its default name in tmpdir instead.
subprocess.run(
["smbget", "-au", f"smb://{SMB_SERVER}/{SMB_SHARE}/{manifest['zip_path']}"],
check=True, timeout=1800, cwd=tmpdir,
)
with state_lock:
install_state[name] = "extracting"
os.makedirs(GAMES_HOME, exist_ok=True)
subprocess.run(["unzip", "-o", "-d", GAMES_HOME, zip_path], check=True, timeout=600)
with state_lock:
install_state[name] = "done"
except Exception as exc:
with state_lock:
install_state[name] = f"error: {exc}"
finally:
subprocess.run(["rm", "-rf", tmpdir], check=False)
def uninstall_game(name):
with state_lock:
if is_running(name):
return
subprocess.run(["rm", "-rf", os.path.join(GAMES_HOME, name)], check=False)
install_state.pop(name, None)
def start_game(name, manifest):
with state_lock:
if is_running(name):
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)"
return
game_errors.pop(name, None)
_clean_display(display_num)
display = f":{display_num}"
rfb_port = f"59{display_num}"
novnc_port = int(f"80{display_num}")
game_env = dict(os.environ, DISPLAY=display)
xvnc = subprocess.Popen(
["Xvnc", display, "-geometry", f"{SCREEN_W}x{SCREEN_H}", "-depth", "24",
"+xinerama", "-securitytypes", "none"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
fluxbox = subprocess.Popen(
["fluxbox"], env=game_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
websockify = subprocess.Popen(
["websockify", "--web=/usr/share/novnc/", f"--cert={NOVNC_CERT}", f"--key={NOVNC_KEY}",
str(novnc_port), f"localhost:{rfb_port}"],
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)
running_procs[name] = {
"game": game_proc, "xvnc": xvnc, "fluxbox": fluxbox, "websockify": websockify,
"display_num": display_num, "novnc_port": novnc_port,
}
def stop_game(name):
with state_lock:
info = running_procs.get(name)
if info is None:
return
_teardown(info)
with state_lock:
running_procs.pop(name, None)
def render_page():
games = discover_games()
sizes = get_zip_sizes(games)
with state_lock:
pending = dict(install_state)
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()}
slots_used = len(novnc_ports)
any_pending = any(v in ("downloading", "extracting") for v in pending.values())
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending or slots_used else ""
def button(action, name, label):
return (
f'<form method="post" action="/{action}">'
f'<input type="hidden" name="name" value="{html.escape(name)}">'
f'<button type="submit">{label}</button></form>'
)
rows = []
for name, manifest in sorted(games.items()):
title = html.escape(manifest.get("title", name))
release_date = html.escape(manifest.get("release_date", ""))
publisher = html.escape(manifest.get("publisher", ""))
size = sizes.get(name)
size_html = f"{size / 1_000_000:.1f} MB" if size else "?"
status = pending.get(name)
screen_html = ""
if name in novnc_ports:
status_html = '<span class="status busy">Running</span>'
screen_html = f'<a href="/screen/{name}" target="_blank">Open Screen</a>'
actions = button("stop", name, "Stop")
elif status in ("downloading", "extracting"):
status_html = f'<span class="status busy">{status}...</span>'
actions = ""
elif status and status.startswith("error"):
status_html = f'<span class="status err">{html.escape(status)}</span>'
actions = button("install", name, "Retry")
elif is_installed(name):
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")
else:
status_html = "Not installed"
actions = button("install", name, "Install")
rows.append(
f"<tr><td>{title}</td><td>{release_date}</td><td>{publisher}</td>"
f"<td>{size_html}</td><td>{status_html}</td><td>{actions}</td><td>{screen_html}</td></tr>"
)
return f"""<!doctype html>
<html>
<head>
<title>dosbox-novnc game setup</title>
{refresh_tag}
<style>
body {{ font-family: sans-serif; margin: 2em; }}
table {{ border-collapse: collapse; width: 100%; max-width: 800px; }}
th, td {{ text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #ccc; }}
form {{ margin: 0; display: inline; }}
button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
.status.ok {{ color: #2a2; font-weight: bold; }}
.status.busy {{ color: #a70; font-weight: bold; }}
.status.err {{ color: #c22; }}
.slots {{ margin-bottom: 1.5em; }}
</style>
</head>
<body>
<p class="slots">{slots_used}/{MAX_CONCURRENT_GAMES} game slots in use</p>
<h1>DOSBox games</h1>
<table>
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th><th></th></tr>
{''.join(rows) if rows else '<tr><td colspan="7">No game manifests found on the share</td></tr>'}
</table>
</body>
</html>"""
def render_screen_page(name, novnc_port, host):
return f"""<!doctype html>
<html>
<head>
<title>{html.escape(name)}</title>
<style>
html, body {{ margin: 0; padding: 0; height: 100%; overflow: hidden; }}
iframe {{ border: none; width: 100%; height: 100%; }}
#audio-btn {{ position: fixed; top: 1em; right: 1em; z-index: 10; padding: 0.5em 1em; }}
</style>
</head>
<body>
<button id="audio-btn">Enable Sound</button>
<iframe src="https://{host}:{novnc_port}/vnc.html?autoconnect=true"></iframe>
<script>
document.getElementById('audio-btn').addEventListener('click', async () => {{
const btn = document.getElementById('audio-btn');
const ctx = new AudioContext({{ sampleRate: 48000 }});
await ctx.audioWorklet.addModule('/pcm-worklet.js');
const node = new AudioWorkletNode(ctx, 'pcm-worklet-processor', {{ outputChannelCount: [2] }});
node.connect(ctx.destination);
const ws = new WebSocket('ws://{host}:{AUDIO_PORT}/');
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => node.port.postMessage(event.data, [event.data]);
btn.disabled = true;
btn.textContent = 'Sound enabled';
}});
</script>
</body>
</html>"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def _send_html(self, body, status=200):
encoded = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def _send_file(self, path, content_type):
try:
with open(path, "rb") as f:
content = f.read()
except OSError:
self._send_html("Not found", status=404)
return
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def do_GET(self):
path = urlparse(self.path).path
if path == "/":
self._send_html(render_page())
elif path == "/pcm-worklet.js":
self._send_file(os.path.join(SCRIPTS_HOME, "pcm-worklet.js"), "application/javascript")
elif path.startswith("/screen/"):
name = path[len("/screen/"):]
with state_lock:
info = running_procs.get(name)
if info is None:
self._send_html(f"<p>{html.escape(name)} is not running.</p>", status=404)
return
host = self.headers.get("Host", "localhost").rsplit(":", 1)[0]
self._send_html(render_screen_page(name, info["novnc_port"], host))
else:
self._send_html("Not found", status=404)
def do_POST(self):
action = urlparse(self.path).path.lstrip("/")
if action not in ("install", "uninstall", "start", "stop"):
self._send_html("Not found", status=404)
return
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8")
name = parse_qs(body).get("name", [""])[0]
games = discover_games()
if name in games:
manifest = games[name]
if action == "install":
threading.Thread(target=install_game, args=(name, manifest), daemon=True).start()
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()
elif action == "stop":
threading.Thread(target=stop_game, args=(name,), daemon=True).start()
self.send_response(303)
self.send_header("Location", "/")
self.end_headers()
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", SETUP_PORT), Handler).serve_forever()