diff --git a/.github/workflows/duo-pages.yml b/.github/workflows/duo-pages.yml index 8da44dfce..af50ad8a3 100644 --- a/.github/workflows/duo-pages.yml +++ b/.github/workflows/duo-pages.yml @@ -31,6 +31,7 @@ jobs: mkdir -p _site/data/cores _site/docs cp duo.html _site/index.html cp -r duo _site/duo + cp duo/sw.js _site/sw.js cp docs/favicon.ico _site/docs/ cp -r data/compression data/localization data/src _site/data/ cp data/loader.js data/version.json data/emulator.css data/emulator.min.js data/emulator.min.css _site/data/ diff --git a/duo.html b/duo.html index ca26fe86f..9dc309b68 100644 --- a/duo.html +++ b/duo.html @@ -7,8 +7,12 @@ + - + + + + diff --git a/duo/duo.js b/duo/duo.js index 7ad3923d9..fbcf503b5 100644 --- a/duo/duo.js +++ b/duo/duo.js @@ -451,7 +451,12 @@ // Boot EmulatorJS const params = new URLSearchParams(location.search); + // Threads need cross-origin isolation, which the service worker provides + const useThreads = window.crossOriginIsolated === true && typeof SharedArrayBuffer === "function" && params.get("threads") !== "0"; + let starting = false; + function start(gameUrl, name) { + starting = true; render(); const game = document.createElement("div"); game.id = "game"; @@ -473,8 +478,10 @@ window.EJS_color = getComputedStyle(document.body).getPropertyValue("--accent").trim(); window.EJS_DEBUG_XX = params.get("debug") === "1"; window.EJS_askBeforeExit = false; + window.EJS_threads = useThreads; window.EJS_defaultOptions = Object.assign({ "melonds_touch_mode": "Touch", + "melonds_threaded_renderer": useThreads ? "enabled" : "disabled", "virtual-gamepad": "disabled", "menu-bar-button": "hidden" }, opts); @@ -510,5 +517,17 @@ if (params.get("rom")) start("roms/" + params.get("rom"), params.get("rom")); + if ("serviceWorker" in navigator) { + // Reload once when the worker first takes over so the page becomes isolated + navigator.serviceWorker.addEventListener("controllerchange", () => { + let reloaded = false; + try { reloaded = sessionStorage.getItem("duo-ds-isolated") === "1"; } catch (e) {} + if (window.crossOriginIsolated || starting || reloaded) return; + try { sessionStorage.setItem("duo-ds-isolated", "1"); } catch (e) {} + location.reload(); + }); + navigator.serviceWorker.register("sw.js").catch(() => {}); + } + render(); })(); diff --git a/duo/icons/apple-touch-icon.png b/duo/icons/apple-touch-icon.png new file mode 100644 index 000000000..cbaa2a5e8 Binary files /dev/null and b/duo/icons/apple-touch-icon.png differ diff --git a/duo/icons/favicon-32.png b/duo/icons/favicon-32.png new file mode 100644 index 000000000..f91b8a2fb Binary files /dev/null and b/duo/icons/favicon-32.png differ diff --git a/duo/icons/icon-192.png b/duo/icons/icon-192.png new file mode 100644 index 000000000..51577b2fa Binary files /dev/null and b/duo/icons/icon-192.png differ diff --git a/duo/icons/icon-512.png b/duo/icons/icon-512.png new file mode 100644 index 000000000..c981caadf Binary files /dev/null and b/duo/icons/icon-512.png differ diff --git a/duo/icons/icon-maskable-512.png b/duo/icons/icon-maskable-512.png new file mode 100644 index 000000000..e968bd8f5 Binary files /dev/null and b/duo/icons/icon-maskable-512.png differ diff --git a/duo/icons/icon.svg b/duo/icons/icon.svg new file mode 100644 index 000000000..64e538c10 --- /dev/null +++ b/duo/icons/icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/duo/manifest.webmanifest b/duo/manifest.webmanifest new file mode 100644 index 000000000..f2e1cbf94 --- /dev/null +++ b/duo/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "id": "/", + "name": "Duo DS", + "short_name": "Duo DS", + "description": "Dual-screen DS emulator for foldables", + "start_url": "../", + "scope": "../", + "display": "fullscreen", + "display_override": ["fullscreen", "standalone"], + "orientation": "any", + "background_color": "#0c0c10", + "theme_color": "#0c0c10", + "categories": ["games", "entertainment"], + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, + { "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } + ] +} diff --git a/duo/sw.js b/duo/sw.js new file mode 100644 index 000000000..4f37b0491 --- /dev/null +++ b/duo/sw.js @@ -0,0 +1,70 @@ +// Deployed at the site root so it controls the whole app +const CACHE = "duo-ds-v1"; +const PRECACHE = [ + "./", + "duo/duo.css", + "duo/duo.js", + "duo/manifest.webmanifest", + "duo/icons/icon-192.png", + "duo/icons/apple-touch-icon.png", + "data/loader.js", + "data/emulator.min.js", + "data/emulator.min.css", + "data/compression/extract7z.js" +]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim())); +}); + +// Cross-origin isolation lets the threaded core use SharedArrayBuffer +function isolate(res) { + if (!res || res.status === 0 || res.type === "opaque") return res; + const headers = new Headers(res.headers); + headers.set("Cross-Origin-Opener-Policy", "same-origin"); + headers.set("Cross-Origin-Embedder-Policy", "require-corp"); + headers.set("Cross-Origin-Resource-Policy", "same-origin"); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} + +async function networkFirst(req) { + const cache = await caches.open(CACHE); + try { + const res = await fetch(req); + if (res.ok) cache.put(req, res.clone()); + return res; + } catch (err) { + const hit = await cache.match(req, { ignoreSearch: true }); + if (hit) return hit; + throw err; + } +} + +async function staleWhileRevalidate(req, event) { + const cache = await caches.open(CACHE); + const hit = await cache.match(req); + const update = fetch(req).then((res) => { + if (res.ok) cache.put(req, res.clone()); + return res; + }); + if (hit) { + event.waitUntil(update.catch(() => {})); + return hit; + } + return update; +} + +self.addEventListener("fetch", (event) => { + const req = event.request; + const url = new URL(req.url); + if (req.method !== "GET" || url.origin !== self.location.origin) return; + // Cores are large and only change with a deploy, so serve them from cache first + const res = url.pathname.endsWith(".data") ? staleWhileRevalidate(req, event) : networkFirst(req); + event.respondWith(res.then(isolate)); +});