Files
homey/modules/services/emulatorjs-server.py
T
2026-08-01 14:59:43 +03:00

965 lines
36 KiB
Python

#!/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>`). 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 <head> fragment that makes a page installable as a PWA.
PWA_HEAD = """\
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a2e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Games">
<link rel="apple-touch-icon" href="/icon-256.png">"""
# ── 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/<name> 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 = ("""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Game Library</title>
""" + PWA_HEAD + """
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee;
max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; }
h1 { color: #e94560; margin: 0 0 1rem; }
#offline-banner {
display: none; background: #c0392b; color: #fff; text-align: center;
padding: .5rem; font-size: .9rem; margin: -1.5rem -1rem 1rem;
}
body.offline #offline-banner { display: block; }
#search {
width: 100%; padding: .7rem 1rem; font-size: 1rem;
background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
color: #eee; outline: none; margin-bottom: 1.5rem; -webkit-appearance: none;
}
#search:focus { border-color: #e94560; }
#search::placeholder { color: #555; }
.section-label {
grid-column: 1 / -1; font-size: .7rem; font-weight: 700; letter-spacing: .1em;
text-transform: uppercase; color: #9ab; padding-bottom: .4rem;
border-bottom: 1px solid #16213e; margin-top: .25rem;
}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: .875rem; }
.card {
background: #16213e; border-radius: 10px; overflow: hidden; position: relative;
text-decoration: none; color: #eee; display: block;
transition: transform .15s, background .15s, opacity .15s;
}
.card:hover { background: #0f3460; transform: translateY(-2px); }
/* When offline, dim games that are not downloaded (cannot be played). */
body.offline .card:not(.avail) { opacity: .35; pointer-events: none; }
.thumb-wrap { width: 100%; aspect-ratio: 3/4; background: #0d1527;
display: flex; align-items: center; justify-content: center; overflow: hidden; }
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
.no-thumb { font-size: 2.5rem; color: #222; }
.card-body { padding: .6rem .75rem; }
.card-title { font-weight: 600; font-size: .8rem; line-height: 1.3; word-break: break-word;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden; }
.card-sys { font-size: .65rem; color: #9ab; margin-top: .2rem; text-transform: uppercase; }
.star, .dl {
position: absolute; top: .4rem; z-index: 1;
background: rgba(0,0,0,.55); border: none; border-radius: 50%;
width: 2rem; height: 2rem; font-size: 1rem; line-height: 1; cursor: pointer;
color: #888; display: flex; align-items: center; justify-content: center; padding: 0;
transition: background .15s, color .15s;
}
.star { right: .4rem; }
.dl { left: .4rem; }
.star:hover, .dl:hover { background: rgba(0,0,0,.85); }
.star.fav { color: #f5c518; }
.dl.done { color: #2ecc71; }
.empty { color: #555; grid-column: 1 / -1; }
</style>
</head>
<body>
<div id="offline-banner">Offline — showing downloaded games; saves will sync when reconnected</div>
<h1>Game Library</h1>
<input id="search" type="search" placeholder="Search…" autocomplete="off" spellcheck="false">
<div class="grid" id="grid"><p class="empty">Loading…</p></div>
<script>
""" + _IDB_JS + _SHELL_CACHE_JS + """
var allGames = [];
var favSet = new Set();
function thumbErr(img) {
img.parentNode.innerHTML = '<span class="no-thumb">\U0001f3ae</span>';
}
function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function card(g) {
var isFav = favSet.has(g.name);
var thumb = '/thumbnails/' + encodeURIComponent(g.title);
var play = '/play/' + encodeURIComponent(g.name);
return '<a class="card" href="' + play + '" data-rom="' + esc(g.name) + '">'
+ '<div class="thumb-wrap">'
+ '<img src="' + thumb + '" alt="" loading="lazy" onerror="thumbErr(this)">'
+ '</div>'
+ '<div class="card-body">'
+ '<div class="card-title">' + esc(g.title) + '</div>'
+ '<div class="card-sys">' + esc(g.system) + '</div>'
+ '</div>'
+ '<button class="dl" data-name="' + esc(g.name) + '" data-title="' + esc(g.title) + '"'
+ ' onclick="toggleDownload(event,this)" title="Download for offline">⬇</button>'
+ '<button class="star' + (isFav ? ' fav' : '') + '"'
+ ' data-name="' + esc(g.name) + '"'
+ ' onclick="toggleFav(event,this)"'
+ ' title="' + (isFav ? 'Remove from favourites' : 'Add to favourites') + '">'
+ (isFav ? '' : '')
+ '</button>'
+ '</a>';
}
function render(q) {
var term = q.trim().toLowerCase();
var list = term ? allGames.filter(function(g) {
return g.title.toLowerCase().indexOf(term) !== -1;
}) : allGames;
if (!list.length) {
document.getElementById('grid').innerHTML = '<p class="empty">No games found.</p>';
return;
}
var faved = list.filter(function(g) { return favSet.has(g.name); });
var others = list.filter(function(g) { return !favSet.has(g.name); });
var html = '';
if (faved.length && others.length) html += '<div class="section-label">Favourites</div>';
faved.forEach(function(g) { html += card(g); });
if (faved.length && others.length) html += '<div class="section-label">All Games</div>';
others.forEach(function(g) { html += card(g); });
document.getElementById('grid').innerHTML = html;
refreshOffline();
}
async function toggleFav(e, btn) {
e.preventDefault();
e.stopPropagation();
var name = btn.dataset.name;
var adding = !favSet.has(name);
if (adding) favSet.add(name); else favSet.delete(name);
render(document.getElementById('search').value);
await fetch('/favorites/' + encodeURIComponent(name), { method: adding ? 'POST' : 'DELETE' });
}
function setDownloaded(btn, done) {
btn.textContent = done ? '' : '';
btn.classList.toggle('done', done);
btn.title = done ? 'Downloaded — tap to remove' : 'Download for offline';
var cardEl = btn.closest('.card');
if (cardEl) cardEl.classList.toggle('avail', done);
}
async function toggleDownload(e, btn) {
e.preventDefault();
e.stopPropagation();
if (!('caches' in window)) { alert('Offline caching is not supported in this browser.'); return; }
var name = btn.dataset.name;
var title = btn.dataset.title;
var romReq = '/roms/' + encodeURIComponent(name);
var cache = await caches.open('emujs-roms');
var have = await cache.match(romReq);
var playReq = '/play/' + encodeURIComponent(name);
if (have) {
await cache.delete(romReq);
await cache.delete('/thumbnails/' + encodeURIComponent(title));
try { var sh = await caches.open(SHELL_CACHE); await sh.delete(playReq); } catch(_) {}
setDownloaded(btn, false);
return;
}
btn.disabled = true;
btn.textContent = '';
try {
await cache.add(romReq);
try { await cache.add('/thumbnails/' + encodeURIComponent(title)); } catch(_) {}
// Cache the player document too so the page renders offline. The emulator
// core itself is cached separately, when the game is first played online.
try { var shell = await caches.open(SHELL_CACHE); await shell.add(playReq); } catch(_) {}
setDownloaded(btn, true);
} catch(err) {
alert('Download failed: ' + err);
setDownloaded(btn, false);
}
btn.disabled = false;
}
async function refreshOffline() {
if (!('caches' in window)) return;
try {
var cache = await caches.open('emujs-roms');
var keys = await cache.keys();
var have = new Set(
keys.map(function(r) { return decodeURIComponent(new URL(r.url).pathname); })
.filter(function(p) { return p.indexOf('/roms/') === 0 && p !== '/roms/'; })
.map(function(p) { return p.slice('/roms/'.length); })
);
document.querySelectorAll('.dl[data-name]').forEach(function(btn) {
setDownloaded(btn, have.has(btn.dataset.name));
});
} catch(e) {}
}
function updateOnline() {
document.body.classList.toggle('offline', !navigator.onLine);
if (navigator.onLine) syncPendingSaves();
}
document.getElementById('search').addEventListener('input', function() {
render(this.value);
});
window.addEventListener('online', updateOnline);
window.addEventListener('offline', updateOnline);
Promise.all([
fetch('/roms/').then(function(r) { return r.json(); }),
fetch('/favorites').then(function(r) { return r.json(); }).catch(function() { return []; })
]).then(function(results) {
allGames = results[0];
favSet = new Set(results[1]);
render(document.getElementById('search').value);
});
updateOnline();
""" + _SW_REG + """\
</script>
</body>
</html>""")
# ── Player page ────────────────────────────────────────────────────────────────
PLAYER_TMPL = Template("""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$title</title>
""" + PWA_HEAD + """
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #000; }
#game { width: 100vw; height: 100vh; }
#back {
position: fixed; top: .75rem; left: .75rem; z-index: 9999;
color: #fff; text-decoration: none; background: rgba(0,0,0,.6);
padding: .3rem .8rem; border-radius: 6px; font-family: system-ui;
}
#save-btn {
position: fixed; top: .75rem; left: 7.5rem; z-index: 9999;
color: #fff; background: rgba(0,0,0,.6); border: none;
padding: .3rem .8rem; border-radius: 6px;
font-family: system-ui; font-size: 1rem; cursor: pointer;
}
#save-btn:disabled { opacity: .6; cursor: default; }
#offline-banner {
display: none; position: fixed; top: 0; left: 0; right: 0; z-index: 9998;
background: #c0392b; color: #fff; text-align: center;
padding: .5rem; font-family: system-ui; font-size: .9rem;
}
</style>
</head>
<body>
<div id="offline-banner">Offline — saves will sync when reconnected</div>
<a id="back" href="/">&#8592; Library</a>
<button id="save-btn" onclick="manualSave()">Save</button>
<div id="game"></div>
<script>
const GAME = $game_json;
const SAVE_URL = '/saves/' + encodeURIComponent(GAME.name);
""" + _IDB_JS + _SHELL_CACHE_JS + _CACHE_STATIC_JS + """
function showOfflineBanner(msg) {
var b = document.getElementById('offline-banner');
b.textContent = msg || 'Offline — saves will sync when reconnected';
b.style.display = '';
}
function hideOfflineBanner() { document.getElementById('offline-banner').style.display = 'none'; }
async function manualSave() {
var btn = document.getElementById('save-btn');
btn.disabled = true;
btn.textContent = 'Saving...';
await pushSave();
btn.textContent = 'Saved!';
setTimeout(function() { btn.textContent = 'Save'; btn.disabled = false; }, 2000);
}
async function pushSave() {
try {
var data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
if (!data || data.byteLength === 0) return;
if (navigator.onLine) {
var r = await fetch(SAVE_URL, { method: 'PUT', body: data,
headers: { 'Content-Type': 'application/octet-stream' } });
if (r.ok) { await idbDelete(GAME.name); return; }
}
// Offline or server rejected — queue locally to sync later.
await idbPut(GAME.name, new Uint8Array(data));
} catch(e) { console.warn('save failed, stored locally', e); }
}
async function pullSave() {
try {
var sav;
if (navigator.onLine) {
var r = await fetch(SAVE_URL);
if (r.ok) sav = new Uint8Array(await r.arrayBuffer());
}
// Fall back to an unsynced local save (offline, or newer than the server's).
if (!sav || !sav.byteLength) {
var local = await idbGet(GAME.name);
if (local && local.byteLength) sav = local;
}
if (!sav || !sav.byteLength) return;
var gm = window.EJS_emulator.gameManager;
var path = gm.getSaveFilePath();
var parts = path.split('/');
var cp = '';
for (var i = 0; i < parts.length - 1; i++) {
if (!parts[i]) continue;
cp += '/' + parts[i];
if (!gm.FS.analyzePath(cp).exists) gm.FS.mkdir(cp);
}
if (gm.FS.analyzePath(path).exists) gm.FS.unlink(path);
gm.FS.writeFile(path, sav);
gm.loadSaveFiles();
} catch(e) { console.info('no save available', e); }
}
window.EJS_player = '#game';
window.EJS_gameUrl = '/roms/' + encodeURIComponent(GAME.name);
window.EJS_core = GAME.system;
window.EJS_pathtodata = '/static/';
window.EJS_onGameStart = function() {
// Push any offline-queued saves first so the server has the latest, then pull.
// Runs after EmulatorJS is ready so IndexedDB access never blocks startup.
(navigator.onLine ? syncPendingSaves().catch(function(){}) : Promise.resolve())
.then(function() { return pullSave(); })
.then(function() {
setInterval(pushSave, 30000);
// Persist the core/assets this game just loaded so it plays offline next
// time. Delayed pass catches anything fetched slightly after game start.
cacheLoadedStatic();
setTimeout(cacheLoadedStatic, 5000);
window.addEventListener('pagehide', function() {
var data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
if (!data || data.byteLength === 0) return;
if (navigator.onLine) {
navigator.sendBeacon(SAVE_URL, new Blob([data], { type: 'application/octet-stream' }));
} else {
idbPut(GAME.name, new Uint8Array(data)); // best-effort
}
});
window.addEventListener('online', function() {
hideOfflineBanner();
cacheLoadedStatic();
syncPendingSaves().catch(function(){}).then(function() { pullSave(); });
});
window.addEventListener('offline', function() { showOfflineBanner(); });
if (!navigator.onLine) showOfflineBanner();
});
};
if (!navigator.onLine) showOfflineBanner();
// Load EmulatorJS directly — no preflight gate. If the ROM or core is not
// cached while offline, EmulatorJS surfaces its own error.
var s = document.createElement('script');
s.src = '/static/loader.js';
document.body.appendChild(s);
""" + _SW_REG + """\
</script>
</body>
</html>""")
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()