#!/usr/bin/env python3 """EmulatorJS launcher and per-user save-sync server. Saves and favourites are keyed by the Remote-User header (set by Authelia via Caddy forward_auth), so each authenticated user gets their own data. Offline mode (PWA): a Service Worker makes the app installable and playable offline. The design is deliberately *network-first* so online behaviour is identical to having no Service Worker at all — the cache is only ever read when the network is genuinely unreachable, so offline support cannot break online play. - App shell + EmulatorJS static assets (/static/...) are cached in a build-scoped cache (`emujs-shell-`). BUILD_ID changes whenever this file or the EmulatorJS asset path changes, so a redeploy transparently invalidates stale assets — no manual cache-version bump needed. - ROMs are large, so they are NOT cached automatically. The launcher has a per-game "download for offline" button that persists the ROM (and its thumbnail) in a separate, build-independent cache (`emujs-roms`). - Saves made while offline are queued in IndexedDB and synced to the server automatically when connectivity returns. Note: a game can only be *played* offline if (a) its ROM was downloaded, and (b) its emulator core was cached — cores are cached automatically the first time you play any game of that system while online. """ import hashlib import json import os import re import struct import urllib.parse import zipfile import zlib from http.server import BaseHTTPRequestHandler, HTTPServer from string import Template SAVE_DIR = os.environ.get("SAVE_DIR", "/mnt/data/emulatorjs/saves") ROM_DIR = os.environ.get("ROM_DIR", "/mnt/data/emulatorjs/roms") STATIC_DIR = os.environ.get("STATIC_DIR", "/mnt/data/emulatorjs/static") THUMBNAILS_DIR = os.environ.get("THUMBNAILS_DIR", "/mnt/data/emulatorjs/thumbnails") PORT = int(os.environ.get("PORT", "8085")) SAFE_RE = re.compile(r"^[A-Za-z0-9 _\-\.\(\)\[\]',!?:&+#]+$") def _compute_build_id() -> str: """A short id that changes whenever this server file or the static asset path changes. Used to name the Service Worker's shell cache so redeploys invalidate stale cached assets automatically.""" h = hashlib.sha256() try: with open(__file__, "rb") as f: h.update(f.read()) except Exception: pass h.update(STATIC_DIR.encode()) return h.hexdigest()[:12] BUILD_ID = _compute_build_id() ROM_SYSTEMS = { ".gb": "gameboy", ".gbc": "gameboy_color", ".gba": "gba", ".nes": "nes", ".snes": "snes", ".sfc": "snes", ".md": "segaMD", ".gen": "segaMD", ".n64": "n64", ".z64": "n64", } RECOGNIZED_EXTS = set(ROM_SYSTEMS.keys()) | {".zip"} _system_cache: dict = {} def detect_system(filepath: str) -> str: """Return the EmulatorJS core name for a ROM, peeking inside zips.""" if filepath in _system_cache: return _system_cache[filepath] ext = os.path.splitext(filepath)[1].lower() if ext != ".zip": result = ROM_SYSTEMS.get(ext, "gameboy") else: result = "gameboy" try: with zipfile.ZipFile(filepath, "r") as zf: for inner in zf.namelist(): inner_ext = os.path.splitext(inner)[1].lower() if inner_ext in ROM_SYSTEMS: result = ROM_SYSTEMS[inner_ext] break except Exception: pass _system_cache[filepath] = result return result MIME_TYPES = { ".js": "application/javascript", ".css": "text/css", ".wasm": "application/wasm", ".data": "application/octet-stream", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", } def _png_icon(size: int, bg: tuple, fg: tuple) -> bytes: """Generate a solid PWA icon (dark square + centred accent circle) as PNG, using only the standard library. Computed once at startup.""" cx = cy = size / 2.0 r2 = (size * 0.32) ** 2 bg_px = bytes(bg) fg_px = bytes(fg) raw = bytearray() for y in range(size): raw.append(0) # PNG filter type 0 (none) per scanline dy = y + 0.5 - cy dy2 = dy * dy for x in range(size): dx = x + 0.5 - cx raw += fg_px if (dx * dx + dy2) <= r2 else bg_px def chunk(typ: bytes, data: bytes) -> bytes: crc = zlib.crc32(typ + data) & 0xffffffff return struct.pack(">I", len(data)) + typ + data + struct.pack(">I", crc) sig = b"\x89PNG\r\n\x1a\n" ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8-bit truecolor RGB idat = zlib.compress(bytes(raw), 9) return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") ICON_PNG = _png_icon(256, (26, 26, 46), (233, 69, 96)) MANIFEST_JSON = json.dumps({ "name": "Game Library", "short_name": "Games", "start_url": "/", "scope": "/", "display": "standalone", "orientation": "landscape", "background_color": "#1a1a2e", "theme_color": "#1a1a2e", "icons": [{ "src": "/icon-256.png", "sizes": "256x256", "type": "image/png", "purpose": "any maskable", }], }) # HTML fragment that makes a page installable as a PWA. PWA_HEAD = """\ """ # ── Service Worker ───────────────────────────────────────────────────────────── # Network-first everywhere so online play behaves exactly as it would with no # Service Worker; the cache is only read when the network is unreachable. SW_JS = ("""\ const SHELL_CACHE = 'emujs-shell-""" + BUILD_ID + """'; const ROM_CACHE = 'emujs-roms'; // build-independent, holds downloaded ROMs const APP_SHELL = ['/', '/manifest.json', '/icon-256.png', '/static/loader.js']; self.addEventListener('install', function(event) { event.waitUntil( caches.open(SHELL_CACHE).then(function(cache) { // allSettled: a single missing asset must not abort the whole install. return Promise.allSettled(APP_SHELL.map(function(u) { return cache.add(u); })); }) ); self.skipWaiting(); }); self.addEventListener('activate', function(event) { event.waitUntil( caches.keys().then(function(keys) { return Promise.all(keys.map(function(k) { if (k !== SHELL_CACHE && k !== ROM_CACHE) return caches.delete(k); })); }).then(function() { return self.clients.claim(); }) ); }); // Cache a response only if it is a same-origin 200. This avoids poisoning the // cache with Authelia auth redirects or error pages. function cacheable(res) { return res && res.status === 200 && res.type === 'basic'; } function networkFirst(req, cacheName) { return fetch(req).then(function(res) { if (cacheable(res)) { var copy = res.clone(); caches.open(cacheName).then(function(c) { c.put(req, copy).catch(function(){}); }); } return res; }).catch(function() { return caches.open(cacheName).then(function(c) { return c.match(req).then(function(hit) { return hit || new Response('', { status: 503 }); }); }); }); } function cacheFirst(req, cacheName) { return caches.open(cacheName).then(function(c) { return c.match(req).then(function(hit) { if (hit) return hit; return fetch(req).then(function(res) { if (cacheable(res)) c.put(req, res.clone()).catch(function(){}); return res; }).catch(function() { return new Response('', { status: 503 }); }); }); }); } self.addEventListener('fetch', function(event) { var req = event.request; if (req.method !== 'GET') return; // saves/favourites writes: untouched var url = new URL(req.url); if (url.origin !== self.location.origin) return; var path = url.pathname; // The SW itself and dynamic save endpoints: pure passthrough. if (path === '/sw.js' || path.startsWith('/saves/')) return; // Individual ROM file: served from the download cache when present (offline // play), otherwise straight to network with NO implicit caching (ROMs are // large — caching is opt-in via the launcher's download button). if (path.startsWith('/roms/') && path !== '/roms/') { event.respondWith( caches.open(ROM_CACHE).then(function(c) { return c.match(req).then(function(hit) { return hit || fetch(req); }); }) ); return; } // EmulatorJS static assets are immutable within a build, and SHELL_CACHE is // build-scoped, so cache-first is safe and fast (no staleness across builds). if (path.startsWith('/static/')) { event.respondWith(cacheFirst(req, SHELL_CACHE)); return; } // Thumbnails: small and stable — cache-first in the persistent ROM cache so // downloaded games still show artwork offline. if (path.startsWith('/thumbnails/')) { event.respondWith(cacheFirst(req, ROM_CACHE)); return; } // App shell, ROM listing, favourites, player pages, icons/manifest: // network-first with a cache fallback so the library is browsable offline. if (path === '/' || path === '/roms/' || path === '/favorites' || path.startsWith('/play/') || path === '/manifest.json' || path.startsWith('/icon')) { event.respondWith(networkFirst(req, SHELL_CACHE)); return; } // Everything else: default network handling. }); """) # ── Shared JS: Service Worker registration ───────────────────────────────────── _SW_REG = """\ if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(function(e) { console.warn('SW registration failed', e); }); } """ # ── Shared JS: IndexedDB offline save queue ──────────────────────────────────── _IDB_JS = """\ function openIDB() { if (!window.indexedDB) return Promise.reject(new Error('no IDB')); return new Promise(function(resolve, reject) { var req = indexedDB.open('emulatorjs', 1); req.onupgradeneeded = function(e) { e.target.result.createObjectStore('pending-saves', { keyPath: 'name' }); }; req.onsuccess = function(e) { resolve(e.target.result); }; req.onerror = reject; req.onblocked = function() { reject(new Error('IDB blocked')); }; }); } async function idbPut(name, data) { const db = await openIDB(); return new Promise(function(resolve, reject) { const tx = db.transaction('pending-saves', 'readwrite'); tx.objectStore('pending-saves').put({ name: name, data: Array.from(data), ts: Date.now() }); tx.oncomplete = resolve; tx.onerror = reject; }); } async function idbGet(name) { const db = await openIDB(); return new Promise(function(resolve) { const tx = db.transaction('pending-saves', 'readonly'); const req = tx.objectStore('pending-saves').get(name); req.onsuccess = function(e) { resolve(e.target.result ? new Uint8Array(e.target.result.data) : null); }; req.onerror = function() { resolve(null); }; }); } async function idbGetAll() { const db = await openIDB(); return new Promise(function(resolve) { const tx = db.transaction('pending-saves', 'readonly'); const req = tx.objectStore('pending-saves').getAll(); req.onsuccess = function(e) { resolve(e.target.result || []); }; req.onerror = function() { resolve([]); }; }); } async function idbDelete(name) { const db = await openIDB(); return new Promise(function(resolve) { const tx = db.transaction('pending-saves', 'readwrite'); tx.objectStore('pending-saves').delete(name); tx.oncomplete = resolve; tx.onerror = resolve; }); } async function syncPendingSaves() { if (!navigator.onLine) return; try { const pending = await idbGetAll(); for (const entry of pending) { try { const r = await fetch('/saves/' + encodeURIComponent(entry.name), { method: 'PUT', body: new Uint8Array(entry.data), headers: { 'Content-Type': 'application/octet-stream' } }); if (r.ok) await idbDelete(entry.name); } catch(e) { break; } } } catch(e) {} } """ # ── Shared JS: name of the build-scoped shell cache (mirrors the Service Worker) # so pages can populate it directly, bypassing SW-interception timing gaps. _SHELL_CACHE_JS = "var SHELL_CACHE = 'emujs-shell-" + BUILD_ID + "';\n" # ── Shared JS: persist the EmulatorJS assets a game actually loaded ───────────── # Relying on the Service Worker to cache the core (mgba-*.data etc.) during play # is unreliable — on the first visit the SW is not yet controlling the page, so # those fetches are never seen. Instead we read the real URLs the browser loaded # from Resource Timing and write them into the shell cache directly from the page. _CACHE_STATIC_JS = """\ async function cacheLoadedStatic() { if (!('caches' in window) || !navigator.onLine) return; try { var cache = await caches.open(SHELL_CACHE); var urls = performance.getEntriesByType('resource') .map(function(e) { return e.name; }) .filter(function(u) { var x; try { x = new URL(u); } catch(_) { return false; } return x.origin === location.origin && x.pathname.indexOf('/static/') === 0; }); urls.push(location.pathname); // the /play/ document itself var seen = {}; for (var i = 0; i < urls.length; i++) { var u = urls[i]; if (seen[u]) continue; seen[u] = 1; try { if (await cache.match(u)) continue; var r = await fetch(u); if (r && r.status === 200) await cache.put(u, r.clone()); } catch(_) {} } } catch(_) {} } """ # ── Game launcher ────────────────────────────────────────────────────────────── LAUNCHER_HTML = ("""\ Game Library """ + PWA_HEAD + """
Offline — showing downloaded games; saves will sync when reconnected

Game Library

Loading…

""") # ── Player page ──────────────────────────────────────────────────────────────── PLAYER_TMPL = Template("""\ $title """ + PWA_HEAD + """
Offline — saves will sync when reconnected
← Library
""") class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass def send_body(self, data: bytes, ct: str, status: int = 200, extra_headers: dict = None): self.send_response(status) self.send_header("Content-Type", ct) self.send_header("Content-Length", str(len(data))) for k, v in (extra_headers or {}).items(): self.send_header(k, v) self.end_headers() self.wfile.write(data) def err(self, code: int): self.send_response(code) self.end_headers() def get_user(self): u = self.headers.get("Remote-User", "").strip() return u if (u and SAFE_RE.match(u)) else None def safe_name(self, s: str): s = urllib.parse.unquote(s).strip() return s if (s and ".." not in s and "/" not in s and SAFE_RE.match(s)) else None def decoded_path(self): return urllib.parse.unquote(self.path.split("?")[0]) # ── Favourites ───────────────────────────────────────────────────────────── def favs_path(self, user: str) -> str: return os.path.join(SAVE_DIR, user, "favorites.json") def read_favs(self, user: str) -> list: fp = self.favs_path(user) if not os.path.isfile(fp): return [] try: with open(fp) as f: return json.load(f) except Exception: return [] def write_favs(self, user: str, favs: list) -> None: fp = self.favs_path(user) os.makedirs(os.path.dirname(fp), exist_ok=True) tmp = fp + ".tmp" with open(tmp, "w") as f: json.dump(favs, f) os.replace(tmp, fp) def handle_fav_write(self, adding: bool) -> None: p = self.decoded_path() user = self.get_user() if not user: self.err(401); return name = self.safe_name(p[len("/favorites/"):]) if not name: self.err(400); return favs = self.read_favs(user) if adding and name not in favs: favs.append(name) elif not adding and name in favs: favs.remove(name) self.write_favs(user, favs) self.send_response(204) self.end_headers() # ── Saves ────────────────────────────────────────────────────────────────── def write_save(self) -> None: p = self.decoded_path() if not p.startswith("/saves/"): self.err(404); return user = self.get_user() if not user: self.err(401); return name = self.safe_name(p[len("/saves/"):]) if not name: self.err(400); return length = int(self.headers.get("Content-Length", 0)) data = self.rfile.read(length) user_dir = os.path.join(SAVE_DIR, user) os.makedirs(user_dir, exist_ok=True) with open(os.path.join(user_dir, name + ".sav"), "wb") as f: f.write(data) self.send_response(204) self.end_headers() # ── HTTP verbs ───────────────────────────────────────────────────────────── def do_GET(self): p = self.decoded_path() if p == "/sw.js": self.send_body( SW_JS.encode(), "application/javascript", extra_headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"}, ) return if p == "/manifest.json": self.send_body(MANIFEST_JSON.encode(), "application/manifest+json") return if p == "/icon-256.png": self.send_body(ICON_PNG, "image/png", extra_headers={"Cache-Control": "max-age=604800"}) return if p in ("/", ""): self.send_body(LAUNCHER_HTML.encode(), "text/html; charset=utf-8") return if p == "/roms/": roms = [] if os.path.isdir(ROM_DIR): for f in sorted(os.listdir(ROM_DIR)): ext = os.path.splitext(f)[1].lower() if ext in RECOGNIZED_EXTS: roms.append({"name": f, "title": os.path.splitext(f)[0], "system": detect_system(os.path.join(ROM_DIR, f))}) self.send_body(json.dumps(roms).encode(), "application/json") return if p.startswith("/roms/"): name = self.safe_name(p[len("/roms/"):]) if not name: self.err(400); return fp = os.path.join(ROM_DIR, name) if not os.path.isfile(fp): self.err(404); return with open(fp, "rb") as f: self.send_body(f.read(), "application/octet-stream") return if p.startswith("/play/"): name = self.safe_name(p[len("/play/"):]) if not name: self.err(400); return game = {"name": name, "title": os.path.splitext(name)[0], "system": detect_system(os.path.join(ROM_DIR, name))} html = PLAYER_TMPL.substitute(title=game["title"], game_json=json.dumps(game)) self.send_body(html.encode(), "text/html; charset=utf-8") return if p == "/favorites": user = self.get_user() if not user: self.err(401); return self.send_body(json.dumps(self.read_favs(user)).encode(), "application/json") return if p.startswith("/thumbnails/"): title = self.safe_name(p[len("/thumbnails/"):]) if not title: self.err(400); return fp = os.path.join(THUMBNAILS_DIR, title + ".png") if not os.path.isfile(fp): self.err(404); return with open(fp, "rb") as f: self.send_body(f.read(), "image/png") return if p.startswith("/saves/"): user = self.get_user() if not user: self.err(401); return name = self.safe_name(p[len("/saves/"):]) if not name: self.err(400); return fp = os.path.join(SAVE_DIR, user, name + ".sav") if not os.path.isfile(fp): self.err(404); return with open(fp, "rb") as f: self.send_body(f.read(), "application/octet-stream") return if p.startswith("/static/"): rel = p[len("/static/"):] if ".." in rel or not rel: self.err(400); return fp = os.path.join(STATIC_DIR, rel) if not os.path.isfile(fp): self.err(404); return ext = os.path.splitext(rel)[1].lower() with open(fp, "rb") as f: self.send_body(f.read(), MIME_TYPES.get(ext, "application/octet-stream")) return self.err(404) def do_PUT(self): self.write_save() def do_POST(self): p = self.decoded_path() if p.startswith("/saves/"): self.write_save() elif p.startswith("/favorites/"): self.handle_fav_write(adding=True) else: self.err(404) def do_DELETE(self): p = self.decoded_path() if p.startswith("/favorites/"): self.handle_fav_write(adding=False) else: self.err(404) if __name__ == "__main__": HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()