Manifests gain release_date/publisher fields (static historical facts). Install size deliberately isn't a manifest field - get_zip_sizes() looks it up live via a batched smbclient ls (one call per game, single connection, split on each command's trailer line) so it can't go stale if a zip is replaced. Add an "Open noVNC Screen" link at the top of the page. Its href is set by a few lines of client-side JS reading window.location.hostname at render time, since the noVNC port (NOVNC_PORT, now passed into setup_server.py by server.sh alongside SETUP_PORT) differs from the setup port and the container may be reached via different hostnames/IPs - a fixed server-rendered URL would be wrong. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
9.7 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
A Docker image definition for headless DOS/SCUMM games exposed over noVNC (VNC-over-websockets in the browser). It builds on the public base image ich777/novnc-baseimage, which already provides TurboVNC, websockify, fluxbox, and noVNC — this repo adds dosbox/scummvm/alsa-utils (game runtimes) plus unzip/smbclient/python3 for game management, a TLS cert for noVNC, and the entrypoint/lifecycle scripts. Published as jayfield/dosbox-novnc on Docker Hub. scummvm's binary is at /usr/games/scummvm, not /usr/bin (Debian's convention for game packages) and not reliably on $PATH under a non-login shell — manifests reference it by absolute path.
Game data (game installs, sourced as zips from an internal SMB share //vlda-01/software/) is deliberately not baked into the image — one of the games (t7g, "The 7th Guest") is ~930MB of CD-ROM ISOs on its own, more than the rest of the image combined. Instead the image ships empty and games are installed at runtime into a volume, via an HTML setup page (see below).
Commands
There is no build system, linter, or test suite — this is a Dockerfile plus a handful of shell scripts and a small Python HTTP server. Common workflows:
# Build the image
docker build -t jayfield/dosbox-novnc .
# Run it the way it's intended to be run (fixed name, volume, ports)
./run.sh
# Launch a specific installed game inside the running container
./game.sh <game-name> # e.g. ./game.sh stuntcar
There's no create.sh in this project (unlike the other noVNC-family projects) — run.sh is the only launch script, and it's -d --rm (long-lived-but-disposable), not a persistent named container created once via docker create.
There's no automated way to exercise scripts/server.sh, scripts/setup_server.py, or scripts/start_game.sh outside a running container — to test changes, rebuild the image, start a real container, and either watch docker logs or docker exec in.
Architecture
Image layering (Dockerfile):
- Base image supplies TurboVNC (
Xvncon$PATH), websockify (websockifyon$PATH), and noVNC's web client at/usr/share/novnc/. - This layer adds
dosbox/alsa-utils(the actual game runtime) andunzip/smbclient/python3(needed permanently at runtime now, not just at build time — see below), generates a self-signed TLS cert/key at build time (/tmp/novnc.pem,/tmp/novnc.key) for noVNC's HTTPS/WSS listener, and copiesscripts/to/opt/scripts/. ${GAMES_HOME}(/opt/games) is created and declared as aVOLUME— it's meant to be backed by a named volume atdocker runtime (run.shuses-v dosbox-games:/opt/games) so installed games survive--rmcontainer restarts instead of being re-fetched every time.ENTRYPOINTis/opt/scripts/server_start.sh /opt/scripts/server.sh— the signal-handling wrapper invoked with the real startup script as its argument.
Runtime env vars (set at docker run time, not baked into the image): DISPLAY_NUM (X display number, default 99 → DISPLAY=:99), SCREEN_W, SCREEN_H (framebuffer geometry).
Port scheme, all derived from DISPLAY_NUM:
59${DISPLAY_NUM}— raw VNC (RFB), only bound to localhost, not published directly.80${DISPLAY_NUM}— noVNC over HTTPS/WSS. This is the port users connect to from a browser to see/play the game.60${DISPLAY_NUM}—EXPOSEd for consistency with the other noVNC-family projects, but nothing inserver.shactually bridges X11-over-TCP here (nosocatcall) — this project doesn't wire that up, unlikedocker-xserver-novnc/docker-sdr-novnc.70${DISPLAY_NUM}— the game setup UI (scripts/setup_server.py), plain HTTP.run.shpublishes this as7099:7099.
run.sh hardcodes DISPLAY_NUM=99 (implicitly, via the image default) and the 7099 port mapping; update it if DISPLAY_NUM ever changes. Note run.sh does not currently publish the 59xx/80xx noVNC ports to the host — only 7099 (setup) is mapped, plus whatever the container's on the same Docker network can reach directly.
Game installation & lifecycle (scripts/setup_server.py), the key thing that differs from the other noVNC-family projects:
- A stdlib-only Python
ThreadingHTTPServer(no third-party deps, no pip installs) started in the background byserver.shalongside Xvnc/fluxbox/websockify. - Games are not hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own
<name>.jsonmanifest living in theGames\dosbox\catalog directory on the SMB share, withname/title/zip_path/release_date/publisher/start_cmdfields — see the module docstring insetup_server.pyfor the exact schema.discover_games()lists*.jsonunderGames\dosbox\(smbclient -N ... -c 'ls Games\dosbox\*.json'),smbclient gets each one into/tmp/manifests/, and parses it — so dropping a new<name>.jsonon the share is enough to make a game appear, no image rebuild needed.zip_pathis a full path relative to the share root (e.g.Games/Monkey Island/The Secret of Monkey Island.zip), not assumed to live next to its manifest — manifests always live in theGames\dosbox\catalog regardless of where the actual game data sits on the share (the SCUMM games' zips live in their own folders, e.g.Games\Monkey Island\,Games\Indiana Jones\).release_date/publisherare static declared metadata (historical facts, can't be derived from the share); install size deliberately is not a manifest field —get_zip_sizes()looks it up live via a batchedsmbclient ls(onels "<path>"per game in a single connection, results split on each command'sN blocks ... availabletrailer) so it can't go stale if a zip is replaced. GET /renders one row per discovered manifest (title/release/publisher/size/status/actions) plus an "Open noVNC Screen" link at the top. That link'shrefis set by a few lines of inline client-side JS (the only JS on the page) readingwindow.location.hostnameat render time and pointing at${NOVNC_PORT}(passed intosetup_server.pyas an env var byserver.sh, alongsideSETUP_PORT) — has to be client-side since the container may be reached via different hostnames/IPs and the noVNC port differs from the setup port, so a fixed server-rendered URL would be wrong. noVNC'sindex.htmlalready redirects tovnc.html?autoconnect=true, so the link just points at the port root.POST /install:smbget -au(guest auth) the manifest'szip_pathinto a/tmp/setup-<name>scratch dir, thenunzip -o -d ${GAMES_HOME}.smbget -a(guest) cannot be combined with-o(output-file) — they conflict on the underlying-Uoption — so the downloadcds into the scratch dir and letssmbgetsave under its default filename instead. Installed-ness is just "non-empty${GAMES_HOME}/<name>directory exists"; a zip is expected to contain one top-level folder matching its own basename (this happens to already match ScummVM's own game-target IDs for the SCUMM titles, e.g.monkey2.zip→monkey2/— convenient, not enforced by the code).POST /uninstall:rm -rf ${GAMES_HOME}/<name>. Refused while the game is running (stop it first) — there's no auto-stop-then-delete.POST /start:subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/<name>), tracked in an in-process dict keyed by game name (running_procs). This inheritssetup_server.py's own environment, including$DISPLAY, so the launched process renders onto the same Xvnc display fluxbox/noVNC are already serving — no separate X setup needed.POST /stop:terminate()s the tracked process, escalating tokill()after a 5s grace period.- Per-game install progress (
install_state) and the running-process table (running_procs) are both guarded by onethreading.RLock(reentrant — several code paths callis_running()from inside a block that already holds the lock). The page does a<meta http-equiv="refresh">every 3s while any install is in flight; the only client-side JS is the noVNC link'shref(above). - Manifests on the share today:
stuntcar(DOSBox),monkey/monkey2/atlantis/indy3/tentacle(ScummVM).t7gstill has no manifest and won't appear on the setup page until one's added.
Playing a game — two overlapping paths right now:
- The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game.
game.sh <name>→docker exec -it dosbox-novnc start_game.sh <name>, running inside the already-running container.scripts/start_game.shhardcodesDOSBOX_VERSION="0.74-3"and therun.bat/dosbox-<version>.confconvention — this predates the manifest system and was deliberately left as-is during the pilot. The two paths don't know about each other (e.g. a game started viagame.shwon't show as "Running" on the setup page). Worth reconciling once manifests cover every game — seeTODO.md.
Signal handling (scripts/server_start.sh + scripts/signals.sh): identical pattern to the other noVNC-family projects — Docker sends SIGTERM to PID 1 on docker stop, server_start.sh traps it, forwards it to the child process tree, waits for the descendants to exit, and re-raises 128 + signal number as its own exit code. Generic ($APP passed as an argument), not specific to server.sh.
Sizing history
See TODO.md for the detailed before/after of the size-reduction work (2.31GB → 1.6GB → ~690MB) — the short version: merge build layers so intermediate files (downloaded zips, build-only packages) don't persist, then stop embedding the actual game data in the image at all and fetch it into a volume on demand instead.