Add web/ — PTY+WebSocket server, xterm.js PWA client, Dockerfile, and PyInstaller notes
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
.idea/
|
.idea/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
|||||||
25
web/Dockerfile
Normal file
25
web/Dockerfile
Normal file
@@ -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"]
|
||||||
46
web/README.md
Normal file
46
web/README.md
Normal file
@@ -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 |
|
||||||
2
web/requirements.txt
Normal file
2
web/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.32.0
|
||||||
89
web/server.py
Normal file
89
web/server.py
Normal file
@@ -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)
|
||||||
30
web/static/generate_icons.py
Normal file
30
web/static/generate_icons.py
Normal file
@@ -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})")
|
||||||
BIN
web/static/icon-192.png
Normal file
BIN
web/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
web/static/icon-512.png
Normal file
BIN
web/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
53
web/static/index.html
Normal file
53
web/static/index.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Kanjin — A text adventure RPG based on the D&D 5e SRD">
|
||||||
|
<meta name="theme-color" content="#1a1a2e">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="icon" href="/static/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||||
|
<title>Kanjin</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; height: 100%; background: #1a1a2e; overflow: hidden; }
|
||||||
|
#terminal { width: 100%; height: 100%; padding: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="terminal"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
|
theme: { background: '#1a1a2e', foreground: '#e0e0e0', cursor: '#e0e0e0' }
|
||||||
|
});
|
||||||
|
const fit = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(document.getElementById('terminal'));
|
||||||
|
fit.fit();
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(proto + '//' + location.host + '/ws');
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
term.focus();
|
||||||
|
term.onData(data => ws.send(data));
|
||||||
|
term.onResize(({ cols, rows }) => {
|
||||||
|
ws.send(JSON.stringify({ resize: [cols, rows] }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (e) => { term.write(e.data); };
|
||||||
|
ws.onclose = () => { term.write('\r\n\x1b[33m[Connection closed. Refresh to restart.]\x1b[0m\r\n'); };
|
||||||
|
window.addEventListener('resize', () => fit.fit());
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
web/static/manifest.json
Normal file
13
web/static/manifest.json
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
22
web/static/sw.js
Normal file
22
web/static/sw.js
Normal file
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user