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));
+});