Video and audio no longer share one iframe-based /screen/<name> page. Firefox refuses to load iframe content signed by a certificate whose warning hasn't been accepted at the top level, with no way to click through inside the iframe (Chrome is more lenient, which is why this briefly looked fine there). "Open Screen" is now a plain top-level link straight to noVNC's own URL (new tab, normal Accept-the-Risk applies), and the "Enable Sound" toggle moved to the main setup page - meant to be left open in its own tab while playing. Because that page now hosts a persistent AudioContext/WebSocket, its auto-refresh no longer fires just because a game is running (only during active installs), since a full-page refresh would have killed that connection every 3s. Two real certificate bugs found and fixed along the way: - CN=NY (a meaningless placeholder) -> CN=localhost + SAN (DNS:localhost, IP:127.0.0.1). Firefox validates the WebSocket-Secure connection's cert against the hostname independently of the page load and rejected the CN mismatch there even after the page-level warning was accepted. - Worse: adding -addext without also pinning basicConstraints=CA:FALSE left the cert defaulting to CA:TRUE - i.e. flagged as a Certificate Authority, not a server cert. Firefox hard-refuses that with no override option at all (not a normal clickable warning), which is why "the warning appears but won't let me proceed" even after the CN fix. Fixed by explicitly setting basicConstraints=critical,CA:FALSE plus keyUsage/extendedKeyUsage=serverAuth. Confirmed fixed: Firefox now shows the same clickable self-signed warning Chrome always did, and the noVNC connection completes successfully after accepting it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
432 lines
17 KiB
Python
Executable File
432 lines
17 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) - simpler than per-slot audio isolation, at the cost of not being
|
|
able to tell games' audio apart when more than one is running.
|
|
|
|
Video and audio are deliberately on separate pages/tabs, not one wrapper
|
|
page with an <iframe>: an iframe onto the noVNC HTTPS port hits browsers
|
|
(Firefox in particular) refusing to load embedded content signed by a
|
|
certificate whose warning hasn't been accepted at the top level, with no
|
|
way to click through *inside* the iframe. So "Open Screen" is a plain
|
|
top-level link straight to noVNC's own URL (opens in a new tab, normal
|
|
"Accept the Risk and Continue" applies), and the one "Enable Sound" toggle
|
|
lives on this page instead, meant to be left open in its own tab for as
|
|
long as you're playing. Because of that, this page's auto-refresh is
|
|
deliberately limited to only while an install is actively in progress -
|
|
refreshing while a game is running would tear down the live AudioContext/
|
|
WebSocket every few seconds.
|
|
"""
|
|
|
|
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(host):
|
|
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())
|
|
# Deliberately not refreshing just because slots_used > 0: this page hosts the one
|
|
# persistent "Enable Sound" AudioContext/WebSocket, and a full-page refresh would kill it.
|
|
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending 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="https://{host}:{novnc_ports[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; }}
|
|
#audio-btn {{ padding: 0.4em 1em; margin-bottom: 1.5em; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<button id="audio-btn">Enable Sound</button>
|
|
<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>
|
|
<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 - keep this tab open while playing';
|
|
}});
|
|
</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 == "/":
|
|
host = self.headers.get("Host", "localhost").rsplit(":", 1)[0]
|
|
self._send_html(render_page(host))
|
|
elif path == "/pcm-worklet.js":
|
|
self._send_file(os.path.join(SCRIPTS_HOME, "pcm-worklet.js"), "application/javascript")
|
|
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()
|