Drive games from per-game JSON manifests instead of raw zip scanning
Games are no longer discovered from *.zip files on the SMB share; each game now has its own <name>.json manifest (name/title/zip/start_cmd) living next to its zip, discovered dynamically via smbclient. Nothing about a game's identity or how to install/start it is hardcoded in the image anymore. The setup page also grew Start/Stop/Uninstall buttons, backed by real process tracking (Popen + terminate/kill). Piloted on stuntcar only (manifest already uploaded to the share); t7g has no manifest yet so it won't appear until one's added. The old game.sh/start_game.sh docker-exec launch path is left as-is for now and overlaps with the new Start button - noted in TODO.md for later cleanup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
This commit is contained in:
@@ -45,14 +45,20 @@ There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server.
|
|||||||
|
|
||||||
`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) 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.
|
||||||
|
|
||||||
**Game installation** (`scripts/setup_server.py`), the key thing that differs from the other noVNC-family projects:
|
**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) started in the background by `server.sh` alongside Xvnc/fluxbox/websockify.
|
||||||
- `GET /` renders an HTML page listing every `*.zip` found live on the SMB share (via `smbclient -N ... -c 'ls Games\dosbox\*.zip'`, parsed with a regex — not hardcoded, so new zips dropped on the share show up automatically) alongside each game's install status, sourced by checking for a non-empty `${GAMES_HOME}/<name>` directory.
|
- 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 next to its zip on the SMB share (e.g. `Games\dosbox\stuntcar.json`), with `name`/`title`/`zip`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` on the share (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `<name>.json` + `<name>.zip` pair on the share is enough to make a game appear, no image rebuild needed.
|
||||||
- `POST /install` (form-submitted, `name=<game>`) validates the name against the live SMB listing, then kicks off `install_game()` in a background daemon thread: `smbget -au` (guest auth) into a `/tmp/setup-<name>` scratch dir, then `unzip -o -d ${GAMES_HOME}`. Per-game state (`downloading`/`extracting`/`done`/`error: ...`) lives in an in-process dict guarded by a lock; the page does a `<meta http-equiv="refresh">` every 3s while anything is in flight, no client-side JS.
|
- `GET /` renders one row per discovered manifest with its live status (Not installed / downloading / extracting / Installed / Running / error) and the matching action buttons.
|
||||||
- `smbget -a` (guest) cannot be combined with `-o` (output-file) — they conflict on the underlying `-U` option — so the download step `cd`s into the scratch dir and lets `smbget` save under its default filename instead.
|
- `POST /install`: `smbget -au` (guest auth) the manifest's `zip` 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.
|
||||||
- Games are recognized purely by directory name under `${GAMES_HOME}`; there's no separate manifest. A zip is expected to contain a single top-level folder matching its own basename (this holds for both `stuntcar.zip`→`stuntcar/` and `t7g.zip`→`t7g/`).
|
- `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.
|
||||||
|
- **Only `stuntcar.json` exists on the share right now** — this whole manifest/start/stop/uninstall mechanism was rolled out as a pilot on that one (small, fast-to-test) game before being extended to `t7g`. Until `t7g.json` is added, `t7g` simply won't appear on the setup page even though its zip is still there.
|
||||||
|
|
||||||
**Playing a game**: `game.sh <name>` is `docker exec -it dosbox-novnc start_game.sh <name>` — it runs *inside* the already-running container (started via `run.sh`), attaching to the same `$DISPLAY` that Xvnc/fluxbox/noVNC are already serving. `scripts/start_game.sh` just runs `dosbox ${GAMES_HOME}/<name>/run.bat -conf ${GAMES_HOME}/<name>/dosbox-0.74-3.conf` — no signal handling of its own, since it's not PID 1 and Docker doesn't manage its lifecycle directly.
|
**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`.
|
||||||
|
|
||||||
**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`.
|
**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`.
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,16 @@
|
|||||||
an HTML page on `${SETUP_PORT}` (`70${DISPLAY_NUM}`, published as `7099` by `run.sh`)
|
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
|
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.
|
user clicks "Install" to fetch+extract a specific game into `${GAMES_HOME}` on demand.
|
||||||
|
|
||||||
|
- **Manifest-driven games + Start/Stop/Uninstall (test balloon, `stuntcar` only)**: games are
|
||||||
|
no longer discovered from raw `*.zip` files on the share. `setup_server.py` now lists
|
||||||
|
`*.json` manifests instead (one per game, e.g. `Games\dosbox\stuntcar.json`), each
|
||||||
|
declaring `name`/`title`/`zip`/`start_cmd`. The web page grew Start/Stop/Uninstall buttons
|
||||||
|
alongside Install, driven entirely by that manifest — nothing about a game's identity or
|
||||||
|
how to run it is hardcoded in the image anymore. Only `stuntcar.json` exists on the share
|
||||||
|
so far (as a deliberate pilot); `t7g` has no manifest yet and will not show up in the setup
|
||||||
|
page until one is added (`t7g.json` with a `start_cmd` for its `dosbox-0.74-3.conf`).
|
||||||
|
`game.sh`/`scripts/start_game.sh` (the old `docker exec`-based launch path, with its
|
||||||
|
hardcoded `DOSBOX_VERSION`/`run.bat` convention) were intentionally left untouched for this
|
||||||
|
pilot and now overlap with the new Start button — worth reconciling (or removing) once the
|
||||||
|
manifest approach is rolled out to all games.
|
||||||
|
|||||||
+141
-49
@@ -1,9 +1,25 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""HTML setup routine: lets the user browse games available on the SMB
|
"""HTML setup routine for DOSBox games.
|
||||||
share and install them into GAMES_HOME on demand, instead of the image
|
|
||||||
auto-downloading everything at container startup."""
|
Nothing about which games exist is hardcoded here: available games are
|
||||||
|
discovered by listing *.json manifests on the SMB share, one per game,
|
||||||
|
each describing how to install/uninstall/start/stop that game. Drop a new
|
||||||
|
<name>.json (and matching zip) on the share and it shows up here with no
|
||||||
|
image changes needed.
|
||||||
|
|
||||||
|
Manifest schema (all fields required):
|
||||||
|
{
|
||||||
|
"name": "stuntcar", # must match the manifest's own filename
|
||||||
|
"title": "Stuntcar Racer",
|
||||||
|
"zip": "stuntcar.zip", # filename under SMB_DIR to fetch on install
|
||||||
|
"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).
|
||||||
|
"""
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -17,33 +33,60 @@ SMB_DIR = r"Games\dosbox"
|
|||||||
GAMES_HOME = os.environ["GAMES_HOME"]
|
GAMES_HOME = os.environ["GAMES_HOME"]
|
||||||
SETUP_PORT = int(os.environ.get("SETUP_PORT", "8080"))
|
SETUP_PORT = int(os.environ.get("SETUP_PORT", "8080"))
|
||||||
|
|
||||||
state_lock = threading.Lock()
|
state_lock = threading.RLock()
|
||||||
install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..."
|
install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..."
|
||||||
|
running_procs = {} # name -> subprocess.Popen
|
||||||
|
|
||||||
|
|
||||||
def list_remote_games():
|
def smb_run(*commands):
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["smbclient", "-N", f"//{SMB_SERVER}/{SMB_SHARE}", "-c", f"ls {SMB_DIR}\\*.zip"],
|
["smbclient", "-N", f"//{SMB_SERVER}/{SMB_SHARE}", "-c", ";".join(commands)],
|
||||||
capture_output=True, text=True, timeout=15,
|
capture_output=True, text=True, timeout=15,
|
||||||
)
|
)
|
||||||
games = []
|
return result.stdout
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
m = re.match(r"\s*(\S+)\.zip\s+A\s+(\d+)", line)
|
|
||||||
if m:
|
|
||||||
games.append((m.group(1), int(m.group(2))))
|
|
||||||
return sorted(games)
|
|
||||||
|
|
||||||
|
|
||||||
def list_installed_games():
|
def discover_games():
|
||||||
if not os.path.isdir(GAMES_HOME):
|
"""Returns {name: manifest} for every *.json manifest found on the share."""
|
||||||
return set()
|
listing = smb_run(f"ls {SMB_DIR}\\*.json")
|
||||||
return {
|
names = [m.group(1) for m in re.finditer(r"\s*(\S+)\.json\s+A\s+\d+", listing)]
|
||||||
name for name in os.listdir(GAMES_HOME)
|
if not names:
|
||||||
if os.path.isdir(os.path.join(GAMES_HOME, name)) and os.listdir(os.path.join(GAMES_HOME, name))
|
return {}
|
||||||
}
|
|
||||||
|
tmpdir = "/tmp/manifests"
|
||||||
|
os.makedirs(tmpdir, exist_ok=True)
|
||||||
|
gets = [f"get {SMB_DIR}\\{name}.json {tmpdir}/{name}.json" for name in names]
|
||||||
|
smb_run(*gets)
|
||||||
|
|
||||||
|
games = {}
|
||||||
|
for name in names:
|
||||||
|
try:
|
||||||
|
with open(f"{tmpdir}/{name}.json") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
if manifest.get("name") == name:
|
||||||
|
games[name] = manifest
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
return games
|
||||||
|
|
||||||
|
|
||||||
def install_game(name):
|
def is_installed(name):
|
||||||
|
path = os.path.join(GAMES_HOME, name)
|
||||||
|
return os.path.isdir(path) and os.listdir(path)
|
||||||
|
|
||||||
|
|
||||||
|
def is_running(name):
|
||||||
|
with state_lock:
|
||||||
|
proc = running_procs.get(name)
|
||||||
|
if proc is None:
|
||||||
|
return False
|
||||||
|
if proc.poll() is None:
|
||||||
|
return True
|
||||||
|
del running_procs[name]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def install_game(name, manifest):
|
||||||
with state_lock:
|
with state_lock:
|
||||||
if install_state.get(name) in ("downloading", "extracting"):
|
if install_state.get(name) in ("downloading", "extracting"):
|
||||||
return
|
return
|
||||||
@@ -52,11 +95,12 @@ def install_game(name):
|
|||||||
tmpdir = f"/tmp/setup-{name}"
|
tmpdir = f"/tmp/setup-{name}"
|
||||||
try:
|
try:
|
||||||
os.makedirs(tmpdir, exist_ok=True)
|
os.makedirs(tmpdir, exist_ok=True)
|
||||||
zip_path = os.path.join(tmpdir, f"{name}.zip")
|
zip_name = manifest["zip"]
|
||||||
|
zip_path = os.path.join(tmpdir, zip_name)
|
||||||
# smbget's -a (guest) can't be combined with -o (output file), so let it save
|
# smbget's -a (guest) can't be combined with -o (output file), so let it save
|
||||||
# under the default name in tmpdir instead, same as the original build-time fetch.
|
# under its default name in tmpdir instead.
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["smbget", "-au", f"smb://{SMB_SERVER}/{SMB_SHARE}/Games/dosbox/{name}.zip"],
|
["smbget", "-au", f"smb://{SMB_SERVER}/{SMB_SHARE}/Games/dosbox/{zip_name}"],
|
||||||
check=True, timeout=1800, cwd=tmpdir,
|
check=True, timeout=1800, cwd=tmpdir,
|
||||||
)
|
)
|
||||||
with state_lock:
|
with state_lock:
|
||||||
@@ -72,36 +116,72 @@ def install_game(name):
|
|||||||
subprocess.run(["rm", "-rf", tmpdir], check=False)
|
subprocess.run(["rm", "-rf", tmpdir], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def uninstall_game(name):
|
||||||
|
with state_lock:
|
||||||
|
if is_running(name):
|
||||||
|
return
|
||||||
|
subprocess.run(["rm", "-rf", os.path.join(GAMES_HOME, name)], check=False)
|
||||||
|
install_state.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def stop_game(name):
|
||||||
|
with state_lock:
|
||||||
|
proc = running_procs.get(name)
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
with state_lock:
|
||||||
|
running_procs.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
def render_page():
|
def render_page():
|
||||||
remote = list_remote_games()
|
games = discover_games()
|
||||||
installed = list_installed_games()
|
|
||||||
with state_lock:
|
with state_lock:
|
||||||
pending = dict(install_state)
|
pending = dict(install_state)
|
||||||
|
|
||||||
any_pending = any(v in ("downloading", "extracting") for v in pending.values())
|
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 else ""
|
||||||
|
|
||||||
|
def button(action, name, label):
|
||||||
|
return (
|
||||||
|
f'<form method="post" action="/{action}">'
|
||||||
|
f'<input type="hidden" name="name" value="{html.escape(name)}">'
|
||||||
|
f'<button type="submit">{label}</button></form>'
|
||||||
|
)
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for name, size in remote:
|
for name, manifest in sorted(games.items()):
|
||||||
|
title = html.escape(manifest.get("title", name))
|
||||||
status = pending.get(name)
|
status = pending.get(name)
|
||||||
if name in installed and status not in ("downloading", "extracting"):
|
if is_running(name):
|
||||||
action = '<span class="status ok">Installed</span>'
|
status_html = '<span class="status busy">Running</span>'
|
||||||
|
actions = button("stop", name, "Stop")
|
||||||
elif status in ("downloading", "extracting"):
|
elif status in ("downloading", "extracting"):
|
||||||
action = f'<span class="status busy">{status}...</span>'
|
status_html = f'<span class="status busy">{status}...</span>'
|
||||||
|
actions = ""
|
||||||
elif status and status.startswith("error"):
|
elif status and status.startswith("error"):
|
||||||
action = (
|
status_html = f'<span class="status err">{html.escape(status)}</span>'
|
||||||
f'<span class="status err">{html.escape(status)}</span>'
|
actions = button("install", name, "Retry")
|
||||||
f'<form method="post" action="/install"><input type="hidden" name="name" value="{html.escape(name)}">'
|
elif is_installed(name):
|
||||||
f'<button type="submit">Retry</button></form>'
|
status_html = '<span class="status ok">Installed</span>'
|
||||||
)
|
actions = button("start", name, "Start") + button("uninstall", name, "Uninstall")
|
||||||
else:
|
else:
|
||||||
action = (
|
status_html = "Not installed"
|
||||||
f'<form method="post" action="/install"><input type="hidden" name="name" value="{html.escape(name)}">'
|
actions = button("install", name, "Install")
|
||||||
f'<button type="submit">Install</button></form>'
|
rows.append(f"<tr><td>{title}</td><td>{status_html}</td><td>{actions}</td></tr>")
|
||||||
)
|
|
||||||
rows.append(
|
|
||||||
f"<tr><td>{html.escape(name)}</td><td>{size / 1_000_000:.1f} MB</td><td>{action}</td></tr>"
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
@@ -112,8 +192,8 @@ def render_page():
|
|||||||
body {{ font-family: sans-serif; margin: 2em; }}
|
body {{ font-family: sans-serif; margin: 2em; }}
|
||||||
table {{ border-collapse: collapse; width: 100%; max-width: 640px; }}
|
table {{ border-collapse: collapse; width: 100%; max-width: 640px; }}
|
||||||
th, td {{ text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #ccc; }}
|
th, td {{ text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #ccc; }}
|
||||||
form {{ margin: 0; }}
|
form {{ margin: 0; display: inline; }}
|
||||||
button {{ padding: 0.3em 0.8em; }}
|
button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
|
||||||
.status.ok {{ color: #2a2; font-weight: bold; }}
|
.status.ok {{ color: #2a2; font-weight: bold; }}
|
||||||
.status.busy {{ color: #a70; font-weight: bold; }}
|
.status.busy {{ color: #a70; font-weight: bold; }}
|
||||||
.status.err {{ color: #c22; }}
|
.status.err {{ color: #c22; }}
|
||||||
@@ -122,8 +202,8 @@ button {{ padding: 0.3em 0.8em; }}
|
|||||||
<body>
|
<body>
|
||||||
<h1>DOSBox games</h1>
|
<h1>DOSBox games</h1>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Game</th><th>Size</th><th>Status / Action</th></tr>
|
<tr><th>Game</th><th>Status</th><th>Actions</th></tr>
|
||||||
{''.join(rows) if rows else '<tr><td colspan="3">No games found on the share</td></tr>'}
|
{''.join(rows) if rows else '<tr><td colspan="3">No game manifests found on the share</td></tr>'}
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -148,15 +228,27 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_html("Not found", status=404)
|
self._send_html("Not found", status=404)
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if urlparse(self.path).path != "/install":
|
action = urlparse(self.path).path.lstrip("/")
|
||||||
|
if action not in ("install", "uninstall", "start", "stop"):
|
||||||
self._send_html("Not found", status=404)
|
self._send_html("Not found", status=404)
|
||||||
return
|
return
|
||||||
|
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
body = self.rfile.read(length).decode("utf-8")
|
body = self.rfile.read(length).decode("utf-8")
|
||||||
name = parse_qs(body).get("name", [""])[0]
|
name = parse_qs(body).get("name", [""])[0]
|
||||||
valid_names = {n for n, _ in list_remote_games()}
|
games = discover_games()
|
||||||
if name in valid_names:
|
|
||||||
threading.Thread(target=install_game, args=(name,), daemon=True).start()
|
if name in games:
|
||||||
|
manifest = games[name]
|
||||||
|
if action == "install":
|
||||||
|
threading.Thread(target=install_game, args=(name, manifest), daemon=True).start()
|
||||||
|
elif action == "uninstall":
|
||||||
|
threading.Thread(target=uninstall_game, args=(name,), daemon=True).start()
|
||||||
|
elif action == "start":
|
||||||
|
threading.Thread(target=start_game, args=(name, manifest), daemon=True).start()
|
||||||
|
elif action == "stop":
|
||||||
|
threading.Thread(target=stop_game, args=(name,), daemon=True).start()
|
||||||
|
|
||||||
self.send_response(303)
|
self.send_response(303)
|
||||||
self.send_header("Location", "/")
|
self.send_header("Location", "/")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|||||||
Reference in New Issue
Block a user