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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
This commit is contained in:
2026-07-28 08:37:12 +02:00
co-authored by Claude Sonnet 5
parent 44bbd00336
commit c6ae36ba67
6 changed files with 181 additions and 29 deletions
+5 -3
View File
@@ -7,10 +7,11 @@ ENV GAMES_HOME=/opt/games
ENV SCRIPTS_HOME=/opt/scripts ENV SCRIPTS_HOME=/opt/scripts
ENV PATH=${PATH}:${SCRIPTS_HOME} ENV PATH=${PATH}:${SCRIPTS_HOME}
# unzip/smbclient are needed at runtime now too: the games are no longer baked into the # unzip/smbclient/python3 are needed at runtime: the games are no longer baked into the
# image, fetch_games.sh pulls them into the ${GAMES_HOME} volume on first container start. # 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 && \ 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/* 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" 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 / WORKDIR /
EXPOSE 60${DISPLAY_NUM} EXPOSE 60${DISPLAY_NUM}
EXPOSE 70${DISPLAY_NUM}
EXPOSE 5900 EXPOSE 5900
ENTRYPOINT ["/opt/scripts/server_start.sh", "/opt/scripts/server.sh"] ENTRYPOINT ["/opt/scripts/server_start.sh", "/opt/scripts/server.sh"]
+7 -4
View File
@@ -5,8 +5,11 @@
layer (so the downloaded zips and the packages only needed to extract them no longer layer (so the downloaded zips and the packages only needed to extract them no longer
persist in the final image). 2.31GB -> 1.6GB. 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 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 ISOs). `run.sh` mounts a named volume (`dosbox-games`) so installed games persist
`${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
across `--rm` restarts. 1.6GB -> ~690MB. Container now needs network access to 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.
+1 -1
View File
@@ -1,4 +1,4 @@
#/bin/bash #/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
-20
View File
@@ -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"
+2 -1
View File
@@ -3,8 +3,9 @@ set -x
RFB_PORT=59${DISPLAY_NUM} RFB_PORT=59${DISPLAY_NUM}
NOVNC_PORT=80${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 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 & Xvnc ${DISPLAY} -geometry ${SCREEN_W}x${SCREEN_H} -depth 24 +xinerama -securitytypes none >/var/log/xvfb.log &
+166
View File
@@ -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 = '<meta http-equiv="refresh" content="3">' 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 = '<span class="status ok">Installed</span>'
elif status in ("downloading", "extracting"):
action = f'<span class="status busy">{status}...</span>'
elif status and status.startswith("error"):
action = (
f'<span class="status err">{html.escape(status)}</span>'
f'<form method="post" action="/install"><input type="hidden" name="name" value="{html.escape(name)}">'
f'<button type="submit">Retry</button></form>'
)
else:
action = (
f'<form method="post" action="/install"><input type="hidden" name="name" value="{html.escape(name)}">'
f'<button type="submit">Install</button></form>'
)
rows.append(
f"<tr><td>{html.escape(name)}</td><td>{size / 1_000_000:.1f} MB</td><td>{action}</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: 640px; }}
th, td {{ text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #ccc; }}
form {{ margin: 0; }}
button {{ padding: 0.3em 0.8em; }}
.status.ok {{ color: #2a2; font-weight: bold; }}
.status.busy {{ color: #a70; font-weight: bold; }}
.status.err {{ color: #c22; }}
</style>
</head>
<body>
<h1>DOSBox games</h1>
<table>
<tr><th>Game</th><th>Size</th><th>Status / Action</th></tr>
{''.join(rows) if rows else '<tr><td colspan="3">No games 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):
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()