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