diff --git a/CLAUDE.md b/CLAUDE.md index 349d61a..ff21668 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A Docker image definition for headless DOSBox 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`/`alsa-utils` 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. +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 (DOS game installs, sourced as zips from an internal SMB share `//vlda-01/software/Games/dosbox/`) is deliberately **not** baked into the image — one of the two 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). +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 @@ -47,14 +47,14 @@ There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server. **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 by `server.sh` alongside Xvnc/fluxbox/websockify. -- Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `.json` manifest living next to its zip on the SMB share (e.g. `Games\dosbox\stuntcar.json`), with `name`/`title`/`zip`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` on the share (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `.json` + `.zip` pair on the share is enough to make a game appear, no image rebuild needed. +- Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `.json` manifest living in the `Games\dosbox\` catalog directory on the SMB share, with `name`/`title`/`zip_path`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` under `Games\dosbox\` (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `.json` on the share is enough to make a game appear, no image rebuild needed. `zip_path` is 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 the `Games\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\`). - `GET /` renders one row per discovered manifest with its live status (Not installed / downloading / extracting / Installed / Running / error) and the matching action buttons. -- `POST /install`: `smbget -au` (guest auth) the manifest's `zip` into a `/tmp/setup-` scratch dir, then `unzip -o -d ${GAMES_HOME}`. `smbget -a` (guest) cannot be combined with `-o` (output-file) — they conflict on the underlying `-U` option — so the download `cd`s into the scratch dir and lets `smbget` save under its default filename instead. Installed-ness is just "non-empty `${GAMES_HOME}/` directory exists"; a zip is expected to contain one top-level folder matching its own basename. +- `POST /install`: `smbget -au` (guest auth) the manifest's `zip_path` into a `/tmp/setup-` scratch dir, then `unzip -o -d ${GAMES_HOME}`. `smbget -a` (guest) cannot be combined with `-o` (output-file) — they conflict on the underlying `-U` option — so the download `cd`s into the scratch dir and lets `smbget` save under its default filename instead. Installed-ness is just "non-empty `${GAMES_HOME}/` 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}/`. 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/)`, tracked in an in-process dict keyed by game name (`running_procs`). This inherits `setup_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 to `kill()` after a 5s grace period. - Per-game install progress (`install_state`) and the running-process table (`running_procs`) are both guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page does a `` every 3s while any install is in flight; no client-side JS anywhere. -- **Only `stuntcar.json` exists on the share right now** — this whole manifest/start/stop/uninstall mechanism was rolled out as a pilot on that one (small, fast-to-test) game before being extended to `t7g`. Until `t7g.json` is added, `t7g` simply won't appear on the setup page even though its zip is still there. +- **Manifests on the share today**: `stuntcar` (DOSBox), `monkey`/`monkey2`/`atlantis`/`indy3`/`tentacle` (ScummVM). `t7g` still has no manifest and won't appear on the setup page until one's added. **Playing a game — two overlapping paths right now**: 1. The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game. diff --git a/Dockerfile b/Dockerfile index eb2ae77..77e8f8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ ENV PATH=${PATH}:${SCRIPTS_HOME} # 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 python3 && \ + apt-get -y install --no-install-recommends dosbox scummvm 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" diff --git a/TODO.md b/TODO.md index e98598c..2bb88a2 100644 --- a/TODO.md +++ b/TODO.md @@ -14,24 +14,44 @@ 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. -- **Manifest-driven games + Start/Stop/Uninstall (test balloon, `stuntcar` only)**: games are - no longer discovered from raw `*.zip` files on the share. `setup_server.py` now lists - `*.json` manifests instead (one per game, e.g. `Games\dosbox\stuntcar.json`), each - declaring `name`/`title`/`zip`/`start_cmd`. The web page grew Start/Stop/Uninstall buttons +- **Manifest-driven games + Start/Stop/Uninstall**: games are no longer discovered from raw + `*.zip` files on the share. `setup_server.py` lists `*.json` manifests instead (one per + game, in the `Games\dosbox\` catalog directory), each declaring + `name`/`title`/`zip_path`/`start_cmd`. The web page grew Start/Stop/Uninstall buttons alongside Install, driven entirely by that manifest — nothing about a game's identity or - how to run it is hardcoded in the image anymore. Only `stuntcar.json` exists on the share - so far (as a deliberate pilot); `t7g` has no manifest yet and will not show up in the setup - page until one is added (`t7g.json` with a `start_cmd` for its `dosbox-0.74-3.conf`). - `game.sh`/`scripts/start_game.sh` (the old `docker exec`-based launch path, with its - hardcoded `DOSBOX_VERSION`/`run.bat` convention) were intentionally left untouched for this - pilot and now overlap with the new Start button — worth reconciling (or removing) once the - manifest approach is rolled out to all games. + how to run it is hardcoded in the image anymore. Started as a `stuntcar`-only pilot, now + extended to 6 games (see the SCUMM item below). `t7g` still has no manifest and won't show + up in the setup page until one is added (`t7g.json` with a `start_cmd` for its + `dosbox-0.74-3.conf`). `game.sh`/`scripts/start_game.sh` (the old `docker exec`-based + launch path, with its hardcoded `DOSBOX_VERSION`/`run.bat` convention) were intentionally + left untouched during the pilot and still overlap with the new Start button — worth + reconciling (or removing) once every remaining game has a manifest. -- Create manifests for SCUMM engine games (Monkey Island, Day of the Tentacle, Indiana Jones, - etc.). Note these are usually best run via ScummVM rather than raw DOSBox — `scummvm` isn't - installed in the image yet, so this may need a Dockerfile change (add the `scummvm` - package) alongside the manifests themselves, and each manifest's `start_cmd` would invoke - `scummvm` with the right game-id instead of `dosbox run.bat -conf ...`. +- ~~Create manifests for SCUMM engine games~~ Done: `scummvm` added to the Dockerfile + (`/usr/games/scummvm`, not on `$PATH` by default under a non-login shell — manifests use + the absolute path). Manifests live in the usual `Games\dosbox\*.json` catalog directory on + the share, but their `zip_path` now points at wherever the game's zip actually already + lived (`Games\Monkey Island\...`, `Games\Indiana Jones\...`, etc.) — this required + generalizing the manifest schema from a bare `zip` filename (implicitly under + `Games\dosbox\`) to a full `zip_path` relative to the share root, since these games weren't + colocated with dosbox's. Added and verified end-to-end (install/start/stop/uninstall) via + `monkey2`: + - `monkey` — The Secret of Monkey Island + - `monkey2` — Monkey Island 2: LeChuck's Revenge + - `atlantis` — Indiana Jones and the Fate of Atlantis + - `indy3` — Indiana Jones and the Last Crusade + - `tentacle` — Day of the Tentacle + + Skipped the German CD release of Day of the Tentacle on the share (`Day Of The Tentacle + (CD DOS, German).zip`) — unlike every other zip here, it doesn't wrap its contents in a + single top-level folder (loose files at the zip root plus a stray `MANIAC/` dir for the + embedded Maniac Mansion easter egg), so it doesn't fit the current "zip's top-level folder + == install name" extraction convention. Also skipped Curse of Monkey Island (much larger, + untested) — could be added the same way if wanted. + + ScummVM's own game-target IDs (`monkey`, `monkey2`, `atlantis`, `indy3`, `tentacle`) + happened to exactly match each zip's existing top-level folder name, so no change was + needed to the install/extraction logic itself, only to where zips are looked up from. - Get game sound actually audible during play. `--device /dev/snd` is passed through and `alsa-utils` is installed, so DOSBox can write to an ALSA device inside the container, but diff --git a/scripts/setup_server.py b/scripts/setup_server.py index 2186e00..c82aa47 100755 --- a/scripts/setup_server.py +++ b/scripts/setup_server.py @@ -11,11 +11,15 @@ Manifest schema (all fields required): { "name": "stuntcar", # must match the manifest's own filename "title": "Stuntcar Racer", - "zip": "stuntcar.zip", # filename under SMB_DIR to fetch on install + "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 "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). +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. """ import html @@ -95,12 +99,12 @@ def install_game(name, manifest): tmpdir = f"/tmp/setup-{name}" try: os.makedirs(tmpdir, exist_ok=True) - zip_name = manifest["zip"] + 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}/Games/dosbox/{zip_name}"], + ["smbget", "-au", f"smb://{SMB_SERVER}/{SMB_SHARE}/{manifest['zip_path']}"], check=True, timeout=1800, cwd=tmpdir, ) with state_lock: