diff --git a/hosts/pi-main/default.nix b/hosts/pi-main/default.nix index b8b99b3..038abb6 100644 --- a/hosts/pi-main/default.nix +++ b/hosts/pi-main/default.nix @@ -84,9 +84,9 @@ homey.nextcloud.enable = true; homey.phpldapadmin.enable = true; - # Media (enable when ready) - homey.jellyfin.enable = false; - homey.transmission.enable = false; + # Media + homey.jellyfin.enable = true; + homey.transmission.enable = true; # Documents and recipes homey.paperless.enable = true; diff --git a/modules/services/emulatorjs-server.py b/modules/services/emulatorjs-server.py index d393df1..29eb964 100644 --- a/modules/services/emulatorjs-server.py +++ b/modules/services/emulatorjs-server.py @@ -4,15 +4,34 @@ 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. +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 @@ -24,6 +43,23 @@ 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", @@ -73,14 +109,75 @@ MIME_TYPES = { ".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 ───────────────────────────────────────────────────────────── -SW_JS = """\ -const CACHE_NAME = 'emulatorjs-v4'; +# 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(CACHE_NAME).then(function(cache) { - return cache.addAll(['/static/loader.js', '/']); + 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(); @@ -89,117 +186,102 @@ self.addEventListener('install', function(event) { 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); }) - ); - }) + return Promise.all(keys.map(function(k) { + if (k !== SHELL_CACHE && k !== ROM_CACHE) return caches.delete(k); + })); + }).then(function() { return self.clients.claim(); }) ); - 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 url = new URL(event.request.url); + 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; - if (path === '/sw.js' || path === '/manifest.json') return; + // The SW itself and dynamic save endpoints: pure passthrough. + if (path === '/sw.js' || path.startsWith('/saves/')) return; - if (path.startsWith('/saves/')) { + // 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( - fetch(event.request).catch(function() { - return new Response('', { status: 503 }); + caches.open(ROM_CACHE).then(function(c) { + return c.match(req).then(function(hit) { return hit || fetch(req); }); }) ); 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 }); - }); - }) - ); + // 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; } - // 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 + // Thumbnails: small and stable — cache-first in the persistent ROM cache so + // downloaded games still show artwork offline. 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 }); }); - }) - ); + event.respondWith(cacheFirst(req, ROM_CACHE)); 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 }); - }); - }) - ); + // 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 ──────────────────────────────────── @@ -277,12 +359,39 @@ async function syncPendingSaves() { } """ -# ── 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: 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(_) {} } """ @@ -294,6 +403,7 @@ LAUNCHER_HTML = ("""\ Game Library +""" + PWA_HEAD + """ -
Offline — saves will sync when reconnected
+
Offline — showing downloaded games; saves will sync when reconnected

Game Library

-
SW: checking… | Online: checking…

Loading…

@@ -501,6 +624,7 @@ PLAYER_TMPL = Template("""\ $title +""" + PWA_HEAD + """