Give each running game its own screen, capped at 10 concurrent

Previously every game shared one always-on Xvnc/fluxbox/websockify
desktop, so starting two games at once meant they fought over focus on
the same screen and the same ALSA device. Now:

- server.sh no longer starts a shared desktop at all - it just runs
  setup_server.py. There's no default display anymore.
- start_game() allocates a free display from GAME_DISPLAY_NUMS
  (:90-:99, one per MAX_CONCURRENT_GAMES=10 slot), spins up a fresh
  Xvnc+fluxbox+websockify for it, and launches the game with DISPLAY set
  to that display. All four processes are tracked together per game.
- stop_game() tears down all four; is_running() does the same lazily if
  the game exited on its own (crash/quit), so a slot doesn't stay stuck
  just because nobody clicked Stop.
- Starting past the 10-slot cap is refused with an error shown on that
  game's row instead of silently failing.
- Each running game's row gets its own "Open Screen" link (client-side
  JS, since the port is only known once the game is actually started)
  instead of one global noVNC link.
- run.sh publishes the whole 8090-8099 noVNC port range up front, since
  Docker can't add port mappings to an already-running container.

Verified end-to-end: two different games running concurrently get fully
independent Xvnc/fluxbox/websockify/game process sets and noVNC
endpoints; stopping one leaves the other untouched; the capacity guard
correctly refuses a start at the limit.

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:25:00 +02:00
co-authored by Claude Sonnet 5
parent 60b8f63ad0
commit 3332553cb5
6 changed files with 148 additions and 57 deletions
+17 -13
View File
@@ -35,30 +35,34 @@ There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server.
- `${GAMES_HOME}` (`/opt/games`) is created and declared as a `VOLUME` — it's meant to be backed by a named volume at `docker run` time (`run.sh` uses `-v dosbox-games:/opt/games`) so installed games survive `--rm` container restarts instead of being re-fetched every time.
- `ENTRYPOINT` is `/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).
**Runtime env vars** (set at `docker run` time, not baked into the image): `DISPLAY_NUM` (only used to compute the setup port, see below — default 99), `SCREEN_W`, `SCREEN_H` (framebuffer geometry, shared by every game's Xvnc instance).
**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}``EXPOSE`d for consistency with the other noVNC-family projects, but nothing in `server.sh` actually bridges X11-over-TCP here (no `socat` call) — this project doesn't wire that up, unlike `docker-xserver-novnc`/`docker-sdr-novnc`.
There is **no single shared desktop/display** — unlike the other noVNC-family projects, this one doesn't run one always-on `Xvnc`+`fluxbox`+`websockify` trio for the whole container lifetime. `server.sh` starts nothing but `setup_server.py`; every game gets its own ephemeral X session, created when it's started and torn down when it's stopped (see below).
**Port scheme**:
- `70${DISPLAY_NUM}` — the game **setup UI** (`scripts/setup_server.py`), plain HTTP. `run.sh` publishes this as `7099:7099`.
- `5990`-`5999` — per-game raw VNC (RFB), one port per concurrent game slot, not published (matches the "RFB stays localhost-only" convention elsewhere in the noVNC family).
- `8090`-`8099` — per-game noVNC over HTTPS/WSS, one port per concurrent game slot (`MAX_CONCURRENT_GAMES = 10` in `setup_server.py`). `run.sh` publishes the whole range (`-p 8090-8099:8090-8099`) up front, since Docker can't add port mappings to an already-running container — a game's actual assigned port within that range is only known once it's started.
- `60${DISPLAY_NUM}``EXPOSE`d for consistency with the other noVNC-family projects, but nothing wires up X11-over-TCP here (no `socat` call) — this project doesn't do that, unlike `docker-xserver-novnc`/`docker-sdr-novnc`.
`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.
`run.sh` hardcodes `DISPLAY_NUM=99` (implicitly, via the image default), only relevant to the `7099` setup port; update it if `DISPLAY_NUM` ever changes.
**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.
- A stdlib-only Python `ThreadingHTTPServer` (no third-party deps, no pip installs) — the *only* thing `server.sh` starts. No Xvnc/fluxbox/websockify run until a game is actually started.
- 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.
- `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a `"{used}/{MAX_CONCURRENT_GAMES} game slots in use"` line at the top.
- `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; the only client-side JS is the noVNC link's `href` (above).
- **`POST /start`: each game gets its own ephemeral X session, not a shared one.** `_free_display_num()` picks an unused display from `GAME_DISPLAY_NUMS` (`:90`-`:99`, one per `MAX_CONCURRENT_GAMES = 10` slot); if none is free, the attempt is refused and `game_errors[name]` is set to a message shown on that game's row (no slot ever gets allocated for it). Otherwise: clean any stale `/tmp/.X{N}-lock`/`/tmp/.X11-unix/X{N}` for that display (the same reason the other noVNC-family projects do this on startup — a prior Xvnc for that display might not have cleaned up after itself), then start `Xvnc :{N}`, `fluxbox`, and `websockify` (bridging `59{N}``80{N}`, no `-D`/daemonize flag — needs to stay a normal foreground child so its `Popen` handle actually reflects whether it's still alive, unlike the old shared-desktop `server.sh` which didn't care), wait ~1s for Xvnc to bind, then `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/<name>, env={..., "DISPLAY": f":{N}"})`. All four `Popen` handles plus the display/port numbers are tracked together in `running_procs[name]`.
- `POST /stop`: tears down all four processes for that game (game first, then websockify/fluxbox/Xvnc), each `terminate()`d then escalated to `kill()` after a 5s grace period, and cleans up the stale-lock files for that display.
- `is_running(name)` does the same full teardown **lazily** if the game process exited on its own (quit or crash) — it won't leave an idle Xvnc/websockify pair holding a slot forever just because nobody clicked Stop. `render_page()` calls this for every tracked game on every load, so the slot count and each row's status stay accurate without polling.
- Each running game's row shows its own "Open Screen" link (only while `Running`), pointing at that game's specific noVNC port. Its `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 — has to be client-side since the container may be reached via different hostnames/IPs and each game's port is only known once it's actually started. noVNC's `index.html` already redirects to `vnc.html?autoconnect=true`, so the link just points at the port root.
- Per-game install progress (`install_state`), start errors (`game_errors`), and the running-process table (`running_procs`) are all guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page auto-refreshes every 3s while any install is in flight or any game is running, so a game that crashed shows its slot freed up on the next load without the user having to do anything.
- **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.
2. `game.sh <name>``docker exec -it dosbox-novnc start_game.sh <name>`, running *inside* the already-running container. `scripts/start_game.sh` hardcodes `DOSBOX_VERSION="0.74-3"` and the `run.bat`/`dosbox-<version>.conf` convention — 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 via `game.sh` won't show as "Running" on the setup page). Worth reconciling once manifests cover every game — see `TODO.md`.
1. The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game, gives it its own screen, and is capped at 10 concurrent.
2. `game.sh <name>``docker exec -it dosbox-novnc start_game.sh <name>`, running *inside* the already-running container. `scripts/start_game.sh` hardcodes `DOSBOX_VERSION="0.74-3"` and the `run.bat`/`dosbox-<version>.conf` convention, and — since there's no more a shared default display at all — has no `$DISPLAY` to render onto unless one happens to be set in the shell's environment. This path predates both the manifest system and the per-game-screen rework and was deliberately left as-is; it's increasingly out of step with the current architecture and worth removing or reworking once every remaining game has a manifest — see `TODO.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`.
+1 -1
View File
@@ -2,7 +2,6 @@
FROM ich777/novnc-baseimage
ENV DISPLAY_NUM=99
ENV DISPLAY=:${DISPLAY_NUM}
ENV GAMES_HOME=/opt/games
ENV SCRIPTS_HOME=/opt/scripts
ENV PATH=${PATH}:${SCRIPTS_HOME}
@@ -25,6 +24,7 @@ WORKDIR /
EXPOSE 60${DISPLAY_NUM}
EXPOSE 70${DISPLAY_NUM}
EXPOSE 8090-8099
EXPOSE 5900
ENTRYPOINT ["/opt/scripts/server_start.sh", "/opt/scripts/server.sh"]
+20 -4
View File
@@ -9,10 +9,10 @@
across `--rm` restarts. 1.6GB -> ~690MB. Container now needs network access to
`vlda-01` at runtime (not just build time) to install games.
3. Dropped the auto-download at container startup entirely. `scripts/setup_server.py` is
a small stdlib-only HTTP server (started alongside Xvnc/fluxbox/websockify) that serves
an HTML page on `${SETUP_PORT}` (`70${DISPLAY_NUM}`, published as `7099` by `run.sh`)
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.
a small stdlib-only HTTP server that serves an HTML page on `${SETUP_PORT}`
(`70${DISPLAY_NUM}`, published as `7099` by `run.sh`) 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**: games are no longer discovered from raw
`*.zip` files on the share. `setup_server.py` lists `*.json` manifests instead (one per
@@ -53,6 +53,22 @@
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.
- ~~Handle multiple games running at once~~ Done: each running game now gets its own
ephemeral X session (`Xvnc`+`fluxbox`+`websockify`, its own display `:90`-`:99` and noVNC
port `8090`-`8099`) spun up by `start_game()` and torn down by `stop_game()` (or lazily on
the next page load, if the game exited/crashed on its own) — instead of every game sharing
one screen and fighting over focus/audio. Capped at `MAX_CONCURRENT_GAMES = 10`; starting
an 11th game while all 10 slots are in use is refused with an error shown on its row rather
than silently doing nothing. There is no more a single shared "default" desktop or global
noVNC link — `server.sh` no longer starts Xvnc/fluxbox/websockify at container startup at
all, only `setup_server.py` itself; each game's own "Open Screen" link appears on its row
only while it's running, built with a little client-side JS (the noVNC port differs from
the setup port and can't be known server-side without knowing which hostname the browser
used to reach the container). `run.sh` publishes the whole `8090-8099` range up front,
since Docker can't add port mappings to an already-running container. Verified end-to-end
with two different games running concurrently (separate processes, separate displays,
separate noVNC ports, independent stop/teardown) and the 10-slot cap logic.
- 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
VNC/noVNC only ever streams video, not audio — nothing currently carries that sound out to
+1 -1
View File
@@ -1,4 +1,4 @@
#/bin/bash
docker run --name dosbox-novnc -d --rm --net bridge -e SCREEN_W=1536 -e SCREEN_H=960 --device /dev/snd -v dosbox-games:/opt/games -p 7099:7099 -p 8099:8099 jayfield/dosbox-novnc
docker run --name dosbox-novnc -d --rm --net bridge -e SCREEN_W=1536 -e SCREEN_H=960 --device /dev/snd -v dosbox-games:/opt/games -p 7099:7099 -p 8090-8099:8090-8099 jayfield/dosbox-novnc
+1 -8
View File
@@ -1,13 +1,6 @@
#!/usr/bin/env bash
set -x
RFB_PORT=59${DISPLAY_NUM}
NOVNC_PORT=80${DISPLAY_NUM}
SETUP_PORT=70${DISPLAY_NUM}
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 &
fluxbox &
websockify -D --web=/usr/share/novnc/ --cert=/tmp/novnc.pem --key=/tmp/novnc.key ${NOVNC_PORT} localhost:${RFB_PORT}
SETUP_PORT=${SETUP_PORT} exec ${SCRIPTS_HOME}/setup_server.py
+108 -30
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""HTML setup routine for DOSBox games.
"""HTML setup routine for DOSBox/ScummVM games.
Nothing about which games exist is hardcoded here: available games are
discovered by listing *.json manifests on the SMB share, one per game,
@@ -23,6 +23,11 @@ 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. Install size isn't a manifest field - it's looked up live from the share
(via `smbclient ls`) so it can't go stale.
Each running game gets its own ephemeral X session (Xvnc + fluxbox +
websockify on their own display/ports) instead of sharing one screen, so up
to MAX_CONCURRENT_GAMES can run side by side without fighting over focus or
being invisible to each other. Starting past that cap is refused.
"""
import html
@@ -31,6 +36,7 @@ import os
import re
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
@@ -39,11 +45,18 @@ 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")
SCREEN_W = os.environ.get("SCREEN_W", "1024")
SCREEN_H = os.environ.get("SCREEN_H", "768")
NOVNC_CERT = "/tmp/novnc.pem"
NOVNC_KEY = "/tmp/novnc.key"
MAX_CONCURRENT_GAMES = 10
GAME_DISPLAY_NUMS = list(range(90, 90 + MAX_CONCURRENT_GAMES)) # :90..:99, one per game slot
state_lock = threading.RLock()
install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..."
running_procs = {} # name -> subprocess.Popen
game_errors = {} # name -> error message from the last failed start attempt
running_procs = {} # name -> {game, xvnc, fluxbox, websockify: Popen, display_num, novnc_port}
def smb_run(*commands):
@@ -100,13 +113,46 @@ def is_installed(name):
return os.path.isdir(path) and os.listdir(path)
def _free_display_num():
used = {info["display_num"] for info in running_procs.values()}
for d in GAME_DISPLAY_NUMS:
if d not in used:
return d
return None
def _clean_display(display_num):
subprocess.run(
["rm", "-f", f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"],
check=False,
)
def _teardown(info):
for key in ("game", "websockify", "fluxbox", "xvnc"):
proc = info[key]
if proc.poll() is None:
proc.terminate()
for key in ("game", "websockify", "fluxbox", "xvnc"):
proc = info[key]
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
_clean_display(info["display_num"])
def is_running(name):
with state_lock:
proc = running_procs.get(name)
if proc is None:
info = running_procs.get(name)
if info is None:
return False
if proc.poll() is None:
if info["game"].poll() is None:
return True
# The game exited on its own (quit/crash) - tear down its X session too
# instead of leaving an idle Xvnc/websockify pair holding the slot.
_teardown(info)
del running_procs[name]
return False
@@ -153,21 +199,46 @@ def start_game(name, manifest):
with state_lock:
if is_running(name):
return
proc = subprocess.Popen(manifest["start_cmd"], cwd=os.path.join(GAMES_HOME, name))
running_procs[name] = proc
display_num = _free_display_num()
if display_num is None:
game_errors[name] = f"no free game slot ({MAX_CONCURRENT_GAMES}/{MAX_CONCURRENT_GAMES} running)"
return
game_errors.pop(name, None)
_clean_display(display_num)
display = f":{display_num}"
rfb_port = f"59{display_num}"
novnc_port = int(f"80{display_num}")
game_env = dict(os.environ, DISPLAY=display)
xvnc = subprocess.Popen(
["Xvnc", display, "-geometry", f"{SCREEN_W}x{SCREEN_H}", "-depth", "24",
"+xinerama", "-securitytypes", "none"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
fluxbox = subprocess.Popen(
["fluxbox"], env=game_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
websockify = subprocess.Popen(
["websockify", "--web=/usr/share/novnc/", f"--cert={NOVNC_CERT}", f"--key={NOVNC_KEY}",
str(novnc_port), f"localhost:{rfb_port}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
time.sleep(1) # give Xvnc a moment to bind before the game connects
game_proc = subprocess.Popen(manifest["start_cmd"], cwd=os.path.join(GAMES_HOME, name), env=game_env)
running_procs[name] = {
"game": game_proc, "xvnc": xvnc, "fluxbox": fluxbox, "websockify": websockify,
"display_num": display_num, "novnc_port": novnc_port,
}
def stop_game(name):
with state_lock:
proc = running_procs.get(name)
if proc is None:
info = running_procs.get(name)
if info is None:
return
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
_teardown(info)
with state_lock:
running_procs.pop(name, None)
@@ -177,9 +248,14 @@ def render_page():
sizes = get_zip_sizes(games)
with state_lock:
pending = dict(install_state)
errors = dict(game_errors)
for name in list(running_procs):
is_running(name) # lazy cleanup of any game that exited on its own
novnc_ports = {name: info["novnc_port"] for name, info in running_procs.items()}
slots_used = len(novnc_ports)
any_pending = any(v in ("downloading", "extracting") for v in pending.values())
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending else ""
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending or slots_used else ""
def button(action, name, label):
return (
@@ -196,8 +272,15 @@ def render_page():
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>'
if name in novnc_ports:
port = novnc_ports[name]
link_id = f"novnc-{name}"
status_html = (
f'<span class="status busy">Running</span> '
f'<a id="{link_id}" href="#" target="_blank">Open Screen</a>'
f"<script>document.getElementById('{link_id}').href = "
f"'https://' + window.location.hostname + ':{port}/';</script>"
)
actions = button("stop", name, "Stop")
elif status in ("downloading", "extracting"):
status_html = f'<span class="status busy">{status}...</span>'
@@ -207,7 +290,10 @@ def render_page():
actions = button("install", name, "Retry")
elif is_installed(name):
status_html = '<span class="status ok">Installed</span>'
actions = button("start", name, "Start") + button("uninstall", name, "Uninstall")
if name in errors:
status_html += f'<br><span class="status err">{html.escape(errors[name])}</span>'
start_button = button("start", name, "Start") if slots_used < MAX_CONCURRENT_GAMES else ""
actions = start_button + button("uninstall", name, "Uninstall")
else:
status_html = "Not installed"
actions = button("install", name, "Install")
@@ -216,14 +302,6 @@ def render_page():
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>
<head>
@@ -238,11 +316,11 @@ 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; }}
.slots {{ margin-bottom: 1.5em; }}
</style>
</head>
<body>
<div class="novnc-link">{novnc_link}</div>
<p class="slots">{slots_used}/{MAX_CONCURRENT_GAMES} game slots in use</p>
<h1>DOSBox games</h1>
<table>
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th></tr>