Redeploy game em

This commit is contained in:
Aner Zakobar
2026-07-30 13:40:44 +03:00
parent cc4803bb82
commit fc457ddf7c
9 changed files with 1353 additions and 25 deletions
+868
View File
@@ -0,0 +1,868 @@
#!/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 = ("""\
<!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>
<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;
}
#dbg-bar {
font-size: .7rem; color: #9ab; margin-bottom: .75rem; font-family: monospace;
background: #0d1527; border-radius: 6px; padding: .4rem .75rem;
}
#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;
}
.card:hover { background: #0f3460; transform: translateY(-2px); }
.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 {
position: absolute; top: .4rem; right: .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:hover { background: rgba(0,0,0,.85); }
.star.fav { color: #f5c518; }
.cache-dot {
display: none; position: absolute; bottom: .4rem; left: .4rem;
width: .55rem; height: .55rem; border-radius: 50%; background: #2ecc71;
title: "Cached for offline";
}
.empty { color: #555; grid-column: 1 / -1; }
</style>
</head>
<body>
<div id="offline-banner">Offline — saves will sync when reconnected</div>
<h1>Game Library</h1>
<div id="dbg-bar">SW: checking… | Online: checking…</div>
<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 + """
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="star' + (isFav ? ' fav' : '') + '"'
+ ' data-name="' + esc(g.name) + '"'
+ ' onclick="toggleFav(event,this)"'
+ ' title="' + (isFav ? 'Remove from favourites' : 'Add to favourites') + '">'
+ (isFav ? '' : '')
+ '</button>'
+ '<span class="cache-dot" title="Cached for offline"></span>'
+ '</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;
updateCacheStatus();
}
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' });
}
document.getElementById('search').addEventListener('input', function() {
render(this.value);
});
async function updateCacheStatus() {
if (!('caches' in window)) return;
try {
const cache = await caches.open('emulatorjs-v4');
const keys = await cache.keys();
const cached = new Set(
keys.map(function(r) {
return decodeURIComponent(new URL(r.url).pathname);
}).filter(function(p) {
return p.startsWith('/roms/');
}).map(function(p) {
return p.slice('/roms/'.length);
})
);
document.querySelectorAll('.card[data-rom]').forEach(function(card) {
if (cached.has(card.dataset.rom)) {
card.querySelector('.cache-dot').style.display = 'block';
}
});
return cached;
} catch(e) { return new Set(); }
}
async function updateDbgBar() {
var swReg = 'none', swCtrl = 'NO';
if ('serviceWorker' in navigator) {
var reg = await navigator.serviceWorker.getRegistration('/');
if (reg) swReg = reg.active ? 'active' : (reg.installing ? 'installing' : 'waiting');
if (navigator.serviceWorker.controller) swCtrl = 'YES';
}
var cached = await updateCacheStatus();
var dbg = document.getElementById('dbg-bar');
dbg.textContent = 'SW reg: ' + swReg + ' ctrl: ' + swCtrl
+ ' | Online: ' + (navigator.onLine ? 'yes' : 'NO')
+ ' | Cached ROMs: ' + (cached ? cached.size : '?');
}
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);
updateDbgBar();
});
window.addEventListener('online', function() {
document.getElementById('offline-banner').style.display = 'none';
syncPendingSaves();
updateDbgBar();
});
window.addEventListener('offline', function() {
document.getElementById('offline-banner').style.display = '';
updateDbgBar();
});
if (!navigator.onLine) { document.getElementById('offline-banner').style.display = ''; }
""" + _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>
<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 + """
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 preflightCheck() {
void('1/4 start');
try {
var swReg = 'none', swCtrl = 'NO';
if ('serviceWorker' in navigator) {
void('2/4 SW reg…');
const reg = await navigator.serviceWorker.getRegistration('/');
if (reg) swReg = reg.active ? 'active' : (reg.installing ? 'installing' : 'waiting');
if (navigator.serviceWorker.controller) swCtrl = 'YES';
}
void('3/4 cache…');
var romCached = false, romInfo = 'NOT in cache';
if ('caches' in window) {
const cache = await caches.open('emulatorjs-v4');
const hit = await cache.match('/roms/' + encodeURIComponent(GAME.name));
if (hit) {
const cl = hit.headers.get('content-length');
const sz = cl ? parseInt(cl) : -1;
romCached = sz > 0;
romInfo = sz > 0 ? 'cached ' + sz + 'B' : 'in cache but 0B (EMPTY!)';
}
}
void('4/4 done — SW: ' + swReg + '/' + swCtrl
+ ' | ROM: ' + romInfo
+ ' | Online: ' + (navigator.onLine ? 'yes' : 'NO')
+ '\n' + GAME.name);
if (!navigator.onLine && !romCached) {
showOfflineBanner('Not available offline — open this game while connected to download it.');
return false;
}
return true;
} catch(e) {
void('preflight error: ' + e + '\n' + GAME.name);
return true; // fail open — try to load EmulatorJS anyway
}
}
async function manualSave() {
const 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 {
const data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
if (!data || data.byteLength === 0) return;
if (navigator.onLine) {
const r = await fetch(SAVE_URL, { method: 'PUT', body: data,
headers: { 'Content-Type': 'application/octet-stream' } });
if (r.ok) return;
}
await idbPut(GAME.name, new Uint8Array(data));
} catch(e) { console.warn('save failed, stored locally', e); }
}
async function pullSave() {
try {
let sav;
if (navigator.onLine) {
const r = await fetch(SAVE_URL);
if (r.ok) sav = new Uint8Array(await r.arrayBuffer());
}
if (!sav) sav = await idbGet(GAME.name);
if (!sav || sav.byteLength === 0) return;
const gm = window.EJS_emulator.gameManager;
const path = gm.getSaveFilePath();
const parts = path.split('/');
let cp = '';
for (let 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() {
// Sync offline-queued saves first so server has latest, then pull.
// Runs after EmulatorJS is ready so IDB access never blocks startup.
(navigator.onLine ? syncPendingSaves().catch(function(){}) : Promise.resolve())
.then(function() { return pullSave(); })
.then(function() {
void('running | Online: ' + (navigator.onLine ? 'yes' : 'NO') + '\n' + GAME.name);
setInterval(pushSave, 30000);
window.addEventListener('pagehide', function() {
const data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
if (data && data.byteLength > 0)
navigator.sendBeacon(SAVE_URL, new Blob([data], { type: 'application/octet-stream' }));
});
window.addEventListener('online', function() {
hideOfflineBanner();
syncPendingSaves().catch(function(){}).then(function() { pullSave(); });
preflightCheck();
});
window.addEventListener('offline', function() { showOfflineBanner(); preflightCheck(); });
if (!navigator.onLine) showOfflineBanner();
});
};
// Preflight: check cache and block if offline + ROM not cached.
// loader.js loads immediately after — syncPendingSaves runs inside EJS_onGameStart
// so IDB access never holds up the startup path.
preflightCheck().then(function(ok) {
if (!ok) return;
var s = document.createElement('script');
s.src = '/static/loader.js';
document.body.appendChild(s);
}).catch(function(e) {
void('startup error: ' + e + '\n' + GAME.name);
var s = document.createElement('script');
s.src = '/static/loader.js';
document.body.appendChild(s);
});
</script>
""" + _SW_REG + """\
</body>
</html>""")
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()