From 60b8f63ad007fee019933521d37f056c6d78d078 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Tue, 28 Jul 2026 15:09:19 +0200 Subject: [PATCH] Add release date/publisher/size info and a noVNC link to the setup page 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 Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz --- CLAUDE.md | 6 ++--- scripts/server.sh | 2 +- scripts/setup_server.py | 49 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ff21668..07e34c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,13 +47,13 @@ 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 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. +- 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`/`release_date`/`publisher`/`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\`). `release_date`/`publisher` are 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 batched `smbclient ls` (one `ls ""` per game in a single connection, results split on each command's `N blocks ... available` trailer) 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's `href` is set by a few lines of inline client-side JS (the only JS on the page) reading `window.location.hostname` at render time and pointing at `${NOVNC_PORT}` (passed into `setup_server.py` as an env var by `server.sh`, alongside `SETUP_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's `index.html` already redirects to `vnc.html?autoconnect=true`, so the link just points at the port root. - `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. +- 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; the only client-side JS is the noVNC link's `href` (above). - **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**: diff --git a/scripts/server.sh b/scripts/server.sh index 3199540..e1dd120 100755 --- a/scripts/server.sh +++ b/scripts/server.sh @@ -5,7 +5,7 @@ RFB_PORT=59${DISPLAY_NUM} NOVNC_PORT=80${DISPLAY_NUM} SETUP_PORT=70${DISPLAY_NUM} -SETUP_PORT=${SETUP_PORT} ${SCRIPTS_HOME}/setup_server.py & +SETUP_PORT=${SETUP_PORT} NOVNC_PORT=${NOVNC_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 index c82aa47..a3110d2 100755 --- a/scripts/setup_server.py +++ b/scripts/setup_server.py @@ -14,12 +14,15 @@ Manifest schema (all fields required): "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 + "release_date": "1989", + "publisher": "MicroStyle", "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). Manifests themselves always live in SMB_DIR (the catalog directory), even though the zip_path they point at may -not. +not. Install size isn't a manifest field - it's looked up live from the share +(via `smbclient ls`) so it can't go stale. """ import html @@ -36,6 +39,7 @@ SMB_SHARE = "software" SMB_DIR = r"Games\dosbox" GAMES_HOME = os.environ["GAMES_HOME"] SETUP_PORT = int(os.environ.get("SETUP_PORT", "8080")) +NOVNC_PORT = os.environ.get("NOVNC_PORT") state_lock = threading.RLock() install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..." @@ -74,6 +78,23 @@ def discover_games(): return games +def get_zip_sizes(games): + """Returns {name: size_bytes or None}, looked up live via smbclient so it can't go stale.""" + if not games: + return {} + names = list(games) + commands = [f'ls "{games[name]["zip_path"].replace("/", chr(92))}"' for name in names] + output = smb_run(*commands) + # Each `ls` result ends with a "N blocks of size 1024. M blocks available" trailer, + # so splitting on that reliably separates one command's output from the next. + chunks = re.split(r"\n\s*\d+ blocks of size \d+\.\s*\d+ blocks available\s*\n?", output) + sizes = {} + for name, chunk in zip(names, chunks): + m = re.search(r"\s+A\s+(\d+)\s", chunk) + sizes[name] = int(m.group(1)) if m else None + return sizes + + def is_installed(name): path = os.path.join(GAMES_HOME, name) return os.path.isdir(path) and os.listdir(path) @@ -153,6 +174,7 @@ def stop_game(name): def render_page(): games = discover_games() + sizes = get_zip_sizes(games) with state_lock: pending = dict(install_state) @@ -169,6 +191,10 @@ def render_page(): rows = [] for name, manifest in sorted(games.items()): title = html.escape(manifest.get("title", name)) + release_date = html.escape(manifest.get("release_date", "")) + publisher = html.escape(manifest.get("publisher", "")) + size = sizes.get(name) + size_html = f"{size / 1_000_000:.1f} MB" if size else "?" status = pending.get(name) if is_running(name): status_html = 'Running' @@ -185,7 +211,18 @@ def render_page(): else: status_html = "Not installed" actions = button("install", name, "Install") - rows.append(f"{title}{status_html}{actions}") + rows.append( + f"{title}{release_date}{publisher}" + f"{size_html}{status_html}{actions}" + ) + + novnc_link = "" + if NOVNC_PORT: + novnc_link = f"""Open noVNC Screen +""" return f""" @@ -194,20 +231,22 @@ def render_page(): {refresh_tag} +

DOSBox games

- -{''.join(rows) if rows else ''} + +{''.join(rows) if rows else ''}
GameStatusActions
No game manifests found on the share
GameReleasePublisherSizeStatusActions
No game manifests found on the share
"""