From c6ae36ba67a41c495af55e8ec05de07f17a9bdf1 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Tue, 28 Jul 2026 08:37:12 +0200 Subject: [PATCH] Replace auto-download with an interactive HTML game setup UI No games are fetched at container start anymore. scripts/setup_server.py is a small stdlib-only Python HTTP server, started alongside Xvnc/fluxbox/ websockify, serving an HTML page on ${SETUP_PORT} (70${DISPLAY_NUM}, published as 7099 by run.sh) that lists every *.zip found live on the SMB share with an Installed/Install status per game. Clicking Install downloads and extracts just that game into ${GAMES_HOME} on demand. Replaces the old fetch_games.sh, which unconditionally pulled every game on first start. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz --- Dockerfile | 8 +- TODO.md | 11 ++- run.sh | 2 +- scripts/fetch_games.sh | 20 ----- scripts/server.sh | 3 +- scripts/setup_server.py | 166 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 29 deletions(-) delete mode 100755 scripts/fetch_games.sh create mode 100755 scripts/setup_server.py diff --git a/Dockerfile b/Dockerfile index 2ebb264..eb2ae77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,11 @@ ENV GAMES_HOME=/opt/games ENV SCRIPTS_HOME=/opt/scripts ENV PATH=${PATH}:${SCRIPTS_HOME} -# unzip/smbclient are needed at runtime now too: the games are no longer baked into the -# image, fetch_games.sh pulls them into the ${GAMES_HOME} volume on first container start. +# unzip/smbclient/python3 are needed at runtime: the games are no longer baked into the +# image, setup_server.py serves an HTML install UI (backed by smbget+unzip) that the user +# drives on ${SETUP_PORT} instead of anything being auto-downloaded at container start. RUN apt-get update && \ - apt-get -y install --no-install-recommends dosbox alsa-utils unzip smbclient && \ + apt-get -y install --no-install-recommends dosbox alsa-utils unzip smbclient python3 && \ rm -rf /var/lib/apt/lists/* RUN openssl req -x509 -nodes -newkey rsa:2048 -keyout /tmp/novnc.key -out /tmp/novnc.pem -days 3650 -subj "/C=US/ST=NY/L=NY/O=NY/OU=NY/CN=NY emailAddress=email@example.com" @@ -23,6 +24,7 @@ ADD scripts ${SCRIPTS_HOME} WORKDIR / EXPOSE 60${DISPLAY_NUM} +EXPOSE 70${DISPLAY_NUM} EXPOSE 5900 ENTRYPOINT ["/opt/scripts/server_start.sh", "/opt/scripts/server.sh"] diff --git a/TODO.md b/TODO.md index 3b6ff0f..594b2fa 100644 --- a/TODO.md +++ b/TODO.md @@ -5,8 +5,11 @@ layer (so the downloaded zips and the packages only needed to extract them no longer persist in the final image). 2.31GB -> 1.6GB. 2. Stopped baking the games into the image at all (927MB of that was just t7g's two CD - ISOs). `scripts/fetch_games.sh` now downloads them from the SMB share into - `${GAMES_HOME}` (`/opt/games`) on first container start instead, skipping the fetch if - already present. `run.sh` mounts a named volume (`dosbox-games`) so the games persist + ISOs). `run.sh` mounts a named volume (`dosbox-games`) so installed games persist across `--rm` restarts. 1.6GB -> ~690MB. Container now needs network access to - `vlda-01` at runtime (not just build time) for the first start. + `vlda-01` at runtime (not just build time) to install games. + 3. Dropped the auto-download at container startup entirely. `scripts/setup_server.py` is + a small stdlib-only HTTP server (started alongside Xvnc/fluxbox/websockify) that serves + an HTML page on `${SETUP_PORT}` (`70${DISPLAY_NUM}`, published as `7099` by `run.sh`) + listing every `*.zip` on the SMB share with an Installed/Install status per game; the + user clicks "Install" to fetch+extract a specific game into `${GAMES_HOME}` on demand. diff --git a/run.sh b/run.sh index 7b91fba..60615b2 100755 --- a/run.sh +++ b/run.sh @@ -1,4 +1,4 @@ #/bin/bash -docker run --name dosbox-novnc -d --rm --net bridge -e SCREEN_W=1536 -e SCREEN_H=960 --device /dev/snd -v dosbox-games:/opt/games jayfield/dosbox-novnc +docker run --name dosbox-novnc -d --rm --net bridge -e SCREEN_W=1536 -e SCREEN_H=960 --device /dev/snd -v dosbox-games:/opt/games -p 7099:7099 jayfield/dosbox-novnc diff --git a/scripts/fetch_games.sh b/scripts/fetch_games.sh deleted file mode 100755 index ca44bc7..0000000 --- a/scripts/fetch_games.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -e - -SMB_BASE="smb://vlda-01/software/Games/dosbox" -GAME_ZIPS="stuntcar.zip t7g.zip" - -if [ -n "$(ls -A "${GAMES_HOME}" 2>/dev/null)" ]; then - echo "Games already present in ${GAMES_HOME}, skipping fetch" - exit 0 -fi - -echo "Fetching games into ${GAMES_HOME}..." -TMPDIR=$(mktemp -d) -trap 'rm -rf "${TMPDIR}"' EXIT - -cd "${TMPDIR}" -for zip in ${GAME_ZIPS}; do - smbget -au "${SMB_BASE}/${zip}" -done -unzip -o -d "${GAMES_HOME}" "*.zip" diff --git a/scripts/server.sh b/scripts/server.sh index 177e920..3199540 100755 --- a/scripts/server.sh +++ b/scripts/server.sh @@ -3,8 +3,9 @@ set -x RFB_PORT=59${DISPLAY_NUM} NOVNC_PORT=80${DISPLAY_NUM} +SETUP_PORT=70${DISPLAY_NUM} -${SCRIPTS_HOME}/fetch_games.sh +SETUP_PORT=${SETUP_PORT} ${SCRIPTS_HOME}/setup_server.py & echo Display at ${DISPLAY} with ${SCREEN_W}x${SCREEN_H}x24 Xvnc ${DISPLAY} -geometry ${SCREEN_W}x${SCREEN_H} -depth 24 +xinerama -securitytypes none >/var/log/xvfb.log & diff --git a/scripts/setup_server.py b/scripts/setup_server.py new file mode 100755 index 0000000..7b10587 --- /dev/null +++ b/scripts/setup_server.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""HTML setup routine: lets the user browse games available on the SMB +share and install them into GAMES_HOME on demand, instead of the image +auto-downloading everything at container startup.""" + +import html +import os +import re +import subprocess +import threading +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")) + +state_lock = threading.Lock() +install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..." + + +def list_remote_games(): + result = subprocess.run( + ["smbclient", "-N", f"//{SMB_SERVER}/{SMB_SHARE}", "-c", f"ls {SMB_DIR}\\*.zip"], + capture_output=True, text=True, timeout=15, + ) + games = [] + for line in result.stdout.splitlines(): + m = re.match(r"\s*(\S+)\.zip\s+A\s+(\d+)", line) + if m: + games.append((m.group(1), int(m.group(2)))) + return sorted(games) + + +def list_installed_games(): + if not os.path.isdir(GAMES_HOME): + return set() + return { + name for name in os.listdir(GAMES_HOME) + if os.path.isdir(os.path.join(GAMES_HOME, name)) and os.listdir(os.path.join(GAMES_HOME, name)) + } + + +def install_game(name): + 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_path = os.path.join(tmpdir, f"{name}.zip") + # smbget's -a (guest) can't be combined with -o (output file), so let it save + # under the default name in tmpdir instead, same as the original build-time fetch. + subprocess.run( + ["smbget", "-au", f"smb://{SMB_SERVER}/{SMB_SHARE}/Games/dosbox/{name}.zip"], + 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 render_page(): + remote = list_remote_games() + installed = list_installed_games() + with state_lock: + pending = dict(install_state) + + any_pending = any(v in ("downloading", "extracting") for v in pending.values()) + refresh_tag = '' if any_pending else "" + + rows = [] + for name, size in remote: + status = pending.get(name) + if name in installed and status not in ("downloading", "extracting"): + action = 'Installed' + elif status in ("downloading", "extracting"): + action = f'{status}...' + elif status and status.startswith("error"): + action = ( + f'{html.escape(status)}' + f'
' + f'
' + ) + else: + action = ( + f'
' + f'
' + ) + rows.append( + f"{html.escape(name)}{size / 1_000_000:.1f} MB{action}" + ) + + return f""" + + +dosbox-novnc game setup +{refresh_tag} + + + +

DOSBox games

+ + +{''.join(rows) if rows else ''} +
GameSizeStatus / Action
No games 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): + if urlparse(self.path).path != "/install": + 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] + valid_names = {n for n, _ in list_remote_games()} + if name in valid_names: + threading.Thread(target=install_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()