#!/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: a Service Worker caches static assets and ROMs in the browser after first play. Saves made while offline are queued in IndexedDB and synced to the server automatically when connectivity is restored. """ import json import os import re import urllib.parse import zipfile 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 _\-\.\(\)\[\]',!?:&+#]+$") 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", } # ── Service Worker ───────────────────────────────────────────────────────────── SW_JS = """\ const CACHE_NAME = 'emulatorjs-v4'; self.addEventListener('install', function(event) { event.waitUntil( caches.open(CACHE_NAME).then(function(cache) { return cache.addAll(['/static/loader.js', '/']); }) ); self.skipWaiting(); }); self.addEventListener('activate', function(event) { event.waitUntil( caches.keys().then(function(keys) { return Promise.all( keys.filter(function(k) { return k !== CACHE_NAME; }) .map(function(k) { return caches.delete(k); }) ); }) ); self.clients.claim(); }); self.addEventListener('fetch', function(event) { var url = new URL(event.request.url); var path = url.pathname; if (path === '/sw.js' || path === '/manifest.json') return; if (path.startsWith('/saves/')) { event.respondWith( fetch(event.request).catch(function() { return new Response('', { status: 503 }); }) ); return; } // Static assets and individual ROM files: cache-first. // Use arrayBuffer() instead of clone() to avoid an iOS Safari bug where // a cloned response body can be empty when the original stream is consumed. if (path.startsWith('/static/') || (path.startsWith('/roms/') && path !== '/roms/')) { event.respondWith( caches.match(event.request).then(function(cached) { if (cached) return cached; return fetch(event.request).then(function(response) { if (!response.ok) return response; var status = response.status; var headers = new Headers(response.headers); return response.arrayBuffer().then(function(body) { caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, new Response(body, { status: status, headers: headers }) ).catch(function(e) { console.warn('[SW] cache.put failed (quota?):', event.request.url, e); }); }); return new Response(body, { status: status, headers: headers }); }); }).catch(function() { return new Response('', { status: 503 }); }); }) ); return; } // Player pages: network-first so new deployments always take effect. // Cache is used only as offline fallback. if (path.startsWith('/play/')) { event.respondWith( fetch(event.request).then(function(response) { var status = response.status, headers = new Headers(response.headers); return response.arrayBuffer().then(function(body) { caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, new Response(body, { status: status, headers: headers })); }); return new Response(body, { status: status, headers: headers }); }); }).catch(function() { return caches.match(event.request).then(function(cached) { return cached || new Response('Offline', { status: 503 }); }); }) ); return; } // Thumbnails: cache on first successful fetch if (path.startsWith('/thumbnails/')) { event.respondWith( caches.match(event.request).then(function(cached) { if (cached) return cached; return fetch(event.request).then(function(response) { if (!response.ok) return response; var status = response.status, headers = new Headers(response.headers); return response.arrayBuffer().then(function(body) { caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, new Response(body, { status: status, headers: headers })); }); return new Response(body, { status: status, headers: headers }); }); }).catch(function() { return new Response('', { status: 404 }); }); }) ); return; } // ROM listing, favorites, launcher: network-first, cache fallback (small text responses) event.respondWith( fetch(event.request).then(function(response) { var status = response.status, headers = new Headers(response.headers); return response.arrayBuffer().then(function(body) { caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, new Response(body, { status: status, headers: headers })); }); return new Response(body, { status: status, headers: headers }); }); }).catch(function() { return caches.match(event.request).then(function(cached) { return cached || new Response('', { status: 503 }); }); }) ); }); """ # ── 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: Service Worker registration ───────────────────────────────────── _SW_REG = """\ if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(function(e) { console.warn('SW registration failed', e); }); } """ # ── Game launcher ────────────────────────────────────────────────────────────── LAUNCHER_HTML = ("""\ Game Library
Offline — saves will sync when reconnected

Game Library

SW: checking… | Online: checking…

Loading…

""") # ── Player page ──────────────────────────────────────────────────────────────── PLAYER_TMPL = Template("""\ $title
Offline — saves will sync when reconnected
← Library
""" + _SW_REG + """\ """) class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass def send_body(self, data: bytes, ct: str, status: int = 200): self.send_response(status) self.send_header("Content-Type", ct) self.send_header("Content-Length", str(len(data))) 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": body = SW_JS.encode() self.send_response(200) self.send_header("Content-Type", "application/javascript") self.send_header("Content-Length", str(len(body))) self.send_header("Service-Worker-Allowed", "/") self.send_header("Cache-Control", "no-cache") self.end_headers() self.wfile.write(body) 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()