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
582 lines
24 KiB
Python
Executable File
582 lines
24 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",
|
|
"programs": [
|
|
{"id": "start", "label": "Start", "cmd": ["dosbox", "run.bat", "-conf", "dosbox-0.74-3.conf"]}
|
|
]
|
|
}
|
|
"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
|
|
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 audio 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.
|
|
|
|
Audio is on by default, no button: the AudioContext/WebSocket connect
|
|
eagerly on page load, and just get resumed on this page's first click or
|
|
keypress (whatever the user was already doing, e.g. clicking Start),
|
|
since browsers require a user gesture before an AudioContext will
|
|
actually produce sound. Per-game volume is a real, independent control
|
|
even though every game's audio mixes into one shared PulseAudio sink -
|
|
each running game is still its own distinct sink-input there, and
|
|
PulseAudio already supports adjusting one sink-input's volume without
|
|
affecting the others (`pactl set-sink-input-volume`), found by matching
|
|
`application.process.id` against the game's own PID.
|
|
"""
|
|
|
|
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, program_label}
|
|
|
|
|
|
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, 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)"
|
|
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(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, "program_label": program["label"],
|
|
}
|
|
|
|
|
|
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 _sink_input_index(pid):
|
|
"""Finds the PulseAudio sink-input belonging to a given PID.
|
|
|
|
All games mix into PulseAudio's one default sink (see module docstring),
|
|
but each game is still its own distinct sink-input there, and PulseAudio
|
|
already supports adjusting one sink-input's volume independently of the
|
|
others - this is what backs the per-game volume slider, without needing
|
|
any per-game audio isolation.
|
|
"""
|
|
result = subprocess.run(
|
|
["pactl", "-f", "json", "list", "sink-inputs"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
try:
|
|
sink_inputs = json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
for sink_input in sink_inputs:
|
|
if sink_input.get("properties", {}).get("application.process.id") == str(pid):
|
|
return sink_input.get("index")
|
|
return None
|
|
|
|
|
|
def set_volume(name, level):
|
|
with state_lock:
|
|
info = running_procs.get(name)
|
|
if info is None:
|
|
return
|
|
index = _sink_input_index(info["game"].pid)
|
|
if index is None:
|
|
return
|
|
subprocess.run(["pactl", "set-sink-input-volume", str(index), f"{level}%"], check=False)
|
|
|
|
|
|
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
|
|
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, 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}\'{program_arg})">{label}</button>'
|
|
)
|
|
|
|
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 = ""
|
|
volume_html = ""
|
|
if name in novnc_ports:
|
|
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" '
|
|
f"oninput=\"fetch('/volume',{{method:'POST',"
|
|
f"headers:{{'Content-Type':'application/x-www-form-urlencoded'}},"
|
|
f"body:'name={name}&level='+this.value}})\">"
|
|
)
|
|
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>'
|
|
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")
|
|
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>"
|
|
f"<td>{screen_html}</td><td>{volume_html}</td></tr>"
|
|
)
|
|
|
|
return f"""<!doctype html>
|
|
<html>
|
|
<head>
|
|
<title>dosbox-novnc game setup</title>
|
|
<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; }}
|
|
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; }}
|
|
.volume {{ width: 80px; vertical-align: middle; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="content">
|
|
<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><th>Volume</th></tr>
|
|
{''.join(rows) if rows else '<tr><td colspan="8">No game manifests found on the share</td></tr>'}
|
|
</table>
|
|
</div>
|
|
<script>
|
|
// None of the buttons/inputs above are <form>s - a real form submit (or any other full
|
|
// page navigation) would tear down the persistent audio AudioContext/WebSocket set up
|
|
// below. doAction()/refresh() fetch() instead and only ever replace #content in place, so
|
|
// the page itself never navigates, no matter how many games get installed/started/stopped.
|
|
async function refresh() {{
|
|
const res = await fetch('/');
|
|
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
|
document.getElementById('content').innerHTML = doc.getElementById('content').innerHTML;
|
|
}}
|
|
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: 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 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 }});
|
|
ctx.audioWorklet.addModule('/pcm-worklet.js').then(() => {{
|
|
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]);
|
|
}});
|
|
const 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);
|
|
</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", "volume"):
|
|
self._send_html("Not found", status=404)
|
|
return
|
|
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(length).decode("utf-8")
|
|
params = parse_qs(body)
|
|
name = params.get("name", [""])[0]
|
|
|
|
if action == "volume":
|
|
# Fired on every slider tick via fetch(), not a form submit - no page navigation.
|
|
try:
|
|
level = int(params.get("level", ["100"])[0])
|
|
except ValueError:
|
|
level = 100
|
|
set_volume(name, level)
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
return
|
|
|
|
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":
|
|
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()
|
|
|
|
# No redirect: the client's own doAction()/refresh() re-fetches "/" itself and
|
|
# replaces #content in place, never navigating the page (see render_page()'s JS).
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ThreadingHTTPServer(("0.0.0.0", SETUP_PORT), Handler).serve_forever()
|