#!/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 = ("""\
Loading…