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:
@@ -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
@@ -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
@@ -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>"""
|
||||
|
||||
Reference in New Issue
Block a user