Was embedded in the Status cell next to the Running badge; now a dedicated column to the right of Actions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
381 lines
14 KiB
Python
Executable File
381 lines
14 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.
|
|
"""
|
|
|
|
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"))
|
|
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"
|
|
|
|
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:
|
|
port = novnc_ports[name]
|
|
link_id = f"novnc-{name}"
|
|
status_html = '<span class="status busy">Running</span>'
|
|
screen_html = (
|
|
f'<a id="{link_id}" href="#" target="_blank">Open Screen</a>'
|
|
f"<script>document.getElementById('{link_id}').href = "
|
|
f"'https://' + window.location.hostname + ':{port}/';</script>"
|
|
)
|
|
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>"""
|
|
|
|
|
|
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 do_GET(self):
|
|
if urlparse(self.path).path == "/":
|
|
self._send_html(render_page())
|
|
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()
|