#!/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 .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/); 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 = '' if any_pending or slots_used else "" def button(action, name, label): return ( f'
' f'' f'
' ) 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 = 'Running' screen_html = ( f'Open Screen' f"" ) actions = button("stop", name, "Stop") elif status in ("downloading", "extracting"): status_html = f'{status}...' actions = "" elif status and status.startswith("error"): status_html = f'{html.escape(status)}' actions = button("install", name, "Retry") elif is_installed(name): status_html = 'Installed' if name in errors: status_html += f'
{html.escape(errors[name])}' 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"{title}{release_date}{publisher}" f"{size_html}{status_html}{actions}{screen_html}" ) return f""" dosbox-novnc game setup {refresh_tag}

{slots_used}/{MAX_CONCURRENT_GAMES} game slots in use

DOSBox games

{''.join(rows) if rows else ''}
GameReleasePublisherSizeStatusActions
No game manifests found on the share
""" 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()