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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
This commit is contained in:
2026-07-28 15:09:19 +02:00
co-authored by Claude Sonnet 5
parent afb18f1b38
commit 60b8f63ad0
3 changed files with 48 additions and 9 deletions
+3 -3
View File
@@ -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 `<name>.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 `<name>.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 `<name>.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 `<name>.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 "<path>"` 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-<name>` 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}/<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 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 `<meta http-equiv="refresh">` 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 `<meta http-equiv="refresh">` 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**:
+1 -1
View File
@@ -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 &
+44 -5
View File
@@ -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/<name>); 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 = '<span class="status busy">Running</span>'
@@ -185,7 +211,18 @@ def render_page():
else:
status_html = "Not installed"
actions = button("install", name, "Install")
rows.append(f"<tr><td>{title}</td><td>{status_html}</td><td>{actions}</td></tr>")
rows.append(
f"<tr><td>{title}</td><td>{release_date}</td><td>{publisher}</td>"
f"<td>{size_html}</td><td>{status_html}</td><td>{actions}</td></tr>"
)
novnc_link = ""
if NOVNC_PORT:
novnc_link = f"""<a id="novnc-link" href="#" target="_blank">Open noVNC Screen</a>
<script>
document.getElementById('novnc-link').href =
'https://' + window.location.hostname + ':{NOVNC_PORT}/';
</script>"""
return f"""<!doctype html>
<html>
@@ -194,20 +231,22 @@ def render_page():
{refresh_tag}
<style>
body {{ font-family: sans-serif; margin: 2em; }}
table {{ border-collapse: collapse; width: 100%; max-width: 640px; }}
table {{ border-collapse: collapse; width: 100%; max-width: 800px; }}
th, td {{ text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #ccc; }}
form {{ margin: 0; display: inline; }}
button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
.status.ok {{ color: #2a2; font-weight: bold; }}
.status.busy {{ color: #a70; font-weight: bold; }}
.status.err {{ color: #c22; }}
.novnc-link {{ margin-bottom: 1.5em; display: block; }}
</style>
</head>
<body>
<div class="novnc-link">{novnc_link}</div>
<h1>DOSBox games</h1>
<table>
<tr><th>Game</th><th>Status</th><th>Actions</th></tr>
{''.join(rows) if rows else '<tr><td colspan="3">No game manifests found on the share</td></tr>'}
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th></tr>
{''.join(rows) if rows else '<tr><td colspan="6">No game manifests found on the share</td></tr>'}
</table>
</body>
</html>"""