From 262292228e1593085f19914a731e6f95a116ed92 Mon Sep 17 00:00:00 2001 From: KansaiGaijin <83641841+KansaiGaijin@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:21:45 +1200 Subject: [PATCH] =?UTF-8?q?Add=20web/=20=E2=80=94=20PTY+WebSocket=20server?= =?UTF-8?q?,=20xterm.js=20PWA=20client,=20Dockerfile,=20and=20PyInstaller?= =?UTF-8?q?=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++ web/Dockerfile | 25 ++++++++++ web/README.md | 46 ++++++++++++++++++ web/requirements.txt | 2 + web/server.py | 89 +++++++++++++++++++++++++++++++++++ web/static/generate_icons.py | 30 ++++++++++++ web/static/icon-192.png | Bin 0 -> 547 bytes web/static/icon-512.png | Bin 0 -> 1880 bytes web/static/index.html | 53 +++++++++++++++++++++ web/static/manifest.json | 13 +++++ web/static/sw.js | 22 +++++++++ 11 files changed, 283 insertions(+) create mode 100644 web/Dockerfile create mode 100644 web/README.md create mode 100644 web/requirements.txt create mode 100644 web/server.py create mode 100644 web/static/generate_icons.py create mode 100644 web/static/icon-192.png create mode 100644 web/static/icon-512.png create mode 100644 web/static/index.html create mode 100644 web/static/manifest.json create mode 100644 web/static/sw.js diff --git a/.gitignore b/.gitignore index ffe7c5a..0d6cdfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .idea/ __pycache__/ *.pyc +build/ +dist/ +*.spec diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..890ae28 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim AS base +WORKDIR /app + +# Install server dependencies +COPY web/requirements.txt /tmp/ +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +# ---------- +FROM base AS builder +COPY web/static/generate_icons.py /tmp/ +RUN python /tmp/generate_icons.py && mv icon-*.png /tmp/ + +# ---------- +FROM base AS runner + +# Game source +COPY main.py player.py scene.py function_list.py class_list.py race_list.py enum_list.py spell_list.py ./ + +# Web server +COPY web/server.py ./web/server.py +COPY web/static/index.html web/static/manifest.json web/static/sw.js ./web/static/ +COPY --from=builder /tmp/icon-192.png /tmp/icon-512.png ./web/static/ + +EXPOSE 8080 +CMD ["uvicorn", "web.server:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..b455540 --- /dev/null +++ b/web/README.md @@ -0,0 +1,46 @@ +# web/ — Kanjin Web Server & PWA + +## Quick start (Docker) + +From the project root: + +```bash +docker build -t kanjin -f web/Dockerfile . +docker run -p 8080:8080 kanjin +``` + +Open http://localhost:8080 in any browser. On Chrome/Edge you can **"Install as app"** via the address bar icon — removes the URL bar and adds a home-screen icon. + +## Windows standalone .exe + +From the project root, with PyInstaller installed: + +```bash +pip install pyinstaller +pyinstaller --onefile --name Kanjin main.py +``` + +Deliver `dist/Kanjin.exe` — no Python or dependencies needed on the player's machine. + +## Development + +```bash +cd web +python -m pip install -r requirements.txt +python generate_icons.py +python server.py # starts uvicorn on :8080 with hot-reload +``` + +Requires Linux or WSL (uses `pty`). The server spawns `main.py` from the parent directory in a pseudo-terminal and streams it to the browser over WebSocket. + +## File layout + +| Path | Purpose | +|---|---| +| `server.py` | FastAPI app: serves index.html, mounts /static, handles /ws WebSocket | +| `static/index.html` | xterm.js in-browser terminal connected over WebSocket | +| `static/manifest.json` | PWA manifest — enables Chrome "Install as app" | +| `static/sw.js` | Service worker — precaches the shell for offline launch | +| `static/generate_icons.py` | Generates placeholder PNG icons (run once) | +| `requirements.txt` | Python deps (fastapi, uvicorn) | +| `Dockerfile` | Multi-stage Docker build | diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..5b0ff56 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 diff --git a/web/server.py b/web/server.py new file mode 100644 index 0000000..9691afe --- /dev/null +++ b/web/server.py @@ -0,0 +1,89 @@ +import asyncio +import os +import signal +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +try: + import pty + HAS_PTY = True +except ImportError: + HAS_PTY = False + +import uvicorn +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles + +app = FastAPI(title="Kanjin") + +STATIC_DIR = Path(__file__).resolve().parent / "static" +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + +@app.get("/") +async def index(): + html = (STATIC_DIR / "index.html").read_text(encoding="utf-8") + return HTMLResponse(html) + + +@app.websocket("/ws") +async def websocket_endpoint(ws: WebSocket): + if not HAS_PTY: + await ws.accept() + await ws.send_text("Server must run on Linux (or WSL) with PTY support.\n") + await ws.close() + return + + await ws.accept() + + master_fd, slave_fd = pty.openpty() + loop = asyncio.get_event_loop() + + proc = await asyncio.create_subprocess_exec( + sys.executable, str(PROJECT_ROOT / "main.py"), + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=str(PROJECT_ROOT), + close_fds=True, + preexec_fn=os.setsid, + ) + os.close(slave_fd) + + async def read_pty(): + try: + while True: + data = await loop.run_in_executor(None, os.read, master_fd, 4096) + if not data: + break + await ws.send_text(data.decode(errors="replace")) + except (ConnectionError, WebSocketDisconnect): + pass + finally: + try: + os.close(master_fd) + except OSError: + pass + if proc.returncode is None: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + await asyncio.wait_for(proc.wait(), timeout=5) + except (ProcessLookupError, asyncio.TimeoutError): + proc.kill() + await proc.wait() + + async def write_pty(): + try: + async for message in ws.iter_text(): + os.write(master_fd, message.encode()) + except (ConnectionError, WebSocketDisconnect): + pass + + await asyncio.gather(read_pty(), write_pty()) + + +if __name__ == "__main__": + uvicorn.run("server:app", host="0.0.0.0", port=8080, reload=True) diff --git a/web/static/generate_icons.py b/web/static/generate_icons.py new file mode 100644 index 0000000..cf3bee6 --- /dev/null +++ b/web/static/generate_icons.py @@ -0,0 +1,30 @@ +"""Generate solid-colour placeholder PNG icons for the PWA manifest.""" +import struct +import zlib +from pathlib import Path + + +def _chunk(chunk_type, data): + c = chunk_type + data + crc = struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) + return struct.pack(">I", len(data)) + c + crc + + +def solid_png(width, height, r, g, b): + header = b"\x89PNG\r\n\x1a\n" + ihdr = _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + raw = bytearray() + for _ in range(height): + raw.append(0) + raw.extend(bytes([r, g, b]) * width) + idat = _chunk(b"IDAT", zlib.compress(bytes(raw))) + iend = _chunk(b"IEND", b"") + return header + ihdr + idat + iend + + +if __name__ == "__main__": + dst = Path(__file__).parent + for size in (192, 512): + path = dst / f"icon-{size}.png" + path.write_bytes(solid_png(size, size, 0x1A, 0x1A, 0x2E)) + print(f"Created {path.name} ({size}x{size})") diff --git a/web/static/icon-192.png b/web/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..0842a714ef0f5c25d73c268b6eb030f4f8f6b819 GIT binary patch literal 547 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$^26i(^Q|oVS-8c^MQ04j7co z;rMg3^m5e VR#TqT9|lG~gQu&X%Q~loCIIm?mOTIf literal 0 HcmV?d00001 diff --git a/web/static/icon-512.png b/web/static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..1abad8706aa1cbdca2e47fe70d5431e25b78a593 GIT binary patch literal 1880 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_pL{PZ!6KiaBqu8uBt2@Eq9i zNTcIpySapI(n*F-sb%sEj>T~d2RNA-I*b?^G)Ae>AQ(*rqZwheEEp{gM?{UVf}fjJ W>}Pzn+CgBA$>8bg=d#Wzp$PzsRecNq literal 0 HcmV?d00001 diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..8c91090 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,53 @@ + + + + + + + + + + +Kanjin + + + + +
+ + + + + diff --git a/web/static/manifest.json b/web/static/manifest.json new file mode 100644 index 0000000..9e7c1d9 --- /dev/null +++ b/web/static/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Kanjin", + "short_name": "Kanjin", + "description": "A text adventure RPG based on the D&D 5e SRD", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#1a1a2e", + "icons": [ + { "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/web/static/sw.js b/web/static/sw.js new file mode 100644 index 0000000..403698e --- /dev/null +++ b/web/static/sw.js @@ -0,0 +1,22 @@ +const CACHE = "kanjin-v1"; +const PRECACHE = ["/", "/static/manifest.json"]; + +self.addEventListener("install", (e) => { + e.waitUntil( + caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (e) => { + e.respondWith( + caches.match(e.request).then((r) => r || fetch(e.request)) + ); +});