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:
+5
-3
@@ -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"]
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 &
|
||||||
|
|||||||
Executable
+166
@@ -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()
|
||||||
Reference in New Issue
Block a user