// Service worker: makes the app installable, opens it without network (the // saved chats open offline) and shows web pushes. const CACHE = "shell-v1"; // The icon badge: `base` is the unread total the app saw last, `pending` the // pushes since (the app resets both when it runs, src/ui/Main.svelte). const BADGE = "badge"; // Navigations can update index.html while the worker script stays identical. // Cache its referenced bundles before replacing the offline page; relying on // script fetch events misses loads served by the browser's memory cache. async function cacheShell(res) { const cache = await caches.open(CACHE); const html = await res.clone().text(); const assets = [...html.matchAll(/(?:src|href)="(?:\.\/)?(assets\/[^"]+)"/g)].map(m => `./${m[1]}`); await Promise.all([...new Set(assets)].map(async asset => { if (await cache.match(asset, { ignoreVary: true })) return; const response = await fetch(asset); if (!response.ok) throw new Error("Could not cache app bundle."); await cache.put(asset, response); })); await cache.addAll(["./config.js", "./manifest.webmanifest", "./icon-192.png"]); await cache.put("./", res); } // Cache the app shell on install: the page plus the bundles it references. // Waiting for the fetch handler isn't enough — Chromium serves repeat loads // from its memory cache, which bypasses the service worker. self.addEventListener("install", (event) => { event.waitUntil( (async () => { const res = await fetch("./", { cache: "no-cache" }); if (!res.ok) throw new Error("Could not cache app page."); await cacheShell(res); })() .catch((err) => console.warn("precache failed", err)) .then(() => self.skipWaiting()), ); }); self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() .then((keys) => Promise.all(keys.filter((k) => k !== CACHE && k !== BADGE).map((k) => caches.delete(k)))) .then(() => self.clients.claim()), ); }); self.addEventListener("fetch", (event) => { const req = event.request; const url = new URL(req.url); // Only our own static files; uploads, the websocket and anything // cross-origin go straight to the network. if (req.method !== "GET" || url.origin !== self.location.origin) return; // Hashed build assets never change: cache first. ignoreVary: module scripts are // requested with an Origin header, the precache request had none. if (url.pathname.includes("/assets/")) { const result = caches.match(req, { ignoreVary: true }).then(hit => hit ? { res: hit, cached: true } : fetch(req).then(res => ({ res, cached: false })), ); event.waitUntil(result.then(({ res, cached }) => { if (!cached && res.ok) { const copy = res.clone(); return caches.open(CACHE).then(c => c.put(req, copy)); } }).catch(() => {})); event.respondWith(result.then(({ res }) => res)); return; } // The page, config.js, manifest and icons: network first, so a deploy is // picked up right away; the cached copy only when offline. const response = fetch(req); // Keep caching alive without holding the page response behind bundle/icon // downloads or storage writes. Register waitUntil during event dispatch. event.waitUntil(response.then(res => { if (!res.ok) return; const copy = res.clone(); if (req.mode === "navigate") return cacheShell(copy); return caches.open(CACHE).then(c => c.put(req, copy)); }).catch(err => console.warn("cache shell", err))); event.respondWith( response.catch(() => caches.match(req.mode === "navigate" ? "./" : req, { ignoreVary: true }).then((hit) => hit || Response.error()), ), ); }); // Web push (PLAN.md, milestone 8): Prosody's mod_push2 sends each message // that arrives while we're away, encrypted for this browser; the payload is // the forwarded in an SCE envelope. iOS drops a subscription // whose pushes show nothing, so every push shows a notification. self.addEventListener("push", (event) => { event.waitUntil(showPush(event.data ? event.data.text() : "")); }); const unescape = (s) => s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&"); function attr(tag, name) { const m = new RegExp(`\\s${name}=(['"])(.*?)\\1`).exec(tag); return m ? unescape(m[2]) : ""; } function parsePush(xml) { const tag = /]*>/.exec(xml)?.[0] ?? ""; const from = attr(tag, "from"); const peer = from.split("/")[0]; const name = peer.split("@")[0] || "0ff chat"; // Calls: the caller hanging up replaces "Incoming call" (same tag). if (/]*urn:xmpp:jingle-message:0/.test(xml)) return { peer, title: name, body: "Incoming call", call: true }; if (/]*urn:xmpp:jingle-message:0/.test(xml)) return { peer, title: name, body: "Missed call", call: true }; const text = /]*)?>([\s\S]*?)<\/body>/.exec(xml); let body = text ? unescape(text[1]) : "New message"; if (/jabber:x:oob/.test(xml)) body = "Attachment"; if (attr(tag, "type") === "groupchat") return { peer, title: `${from.slice(from.indexOf("/") + 1)} in ${name}`, body }; return { peer, title: name, body }; } async function bumpBadge() { const cache = await caches.open(BADGE); const res = await cache.match("./badge-state"); const state = res ? await res.json() : { base: 0, pending: 0 }; state.pending += 1; await cache.put("./badge-state", new Response(JSON.stringify(state))); return state.base + state.pending; } async function showPush(xml) { const n = parsePush(xml); // The badge counts unread messages; calls are not among them. const count = n.call ? 0 : await bumpBadge().catch(() => 0); // Same tag as the in-page notifications, so one replaces the other. iOS // doesn't replace by tag, and mod_push2 pushes calls twice (archive and // session queue), so close the old one first (seen 2026-09-29). const tag = n.peer || "push"; for (const old of await self.registration.getNotifications({ tag }).catch(() => [])) old.close(); await self.registration.showNotification(n.title, { body: n.body.slice(0, 200), tag, icon: "icon-192.png", data: { hash: n.peer ? `#/chat/${encodeURIComponent(n.peer)}` : "" }, }); if (count) await self.navigator.setAppBadge?.(count).catch(() => {}); } // Clicking a notification focuses the app and opens that chat. self.addEventListener("notificationclick", (event) => { event.notification.close(); const hash = event.notification.data?.hash ?? ""; event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((wins) => { const win = wins[0]; if (win) { win.postMessage({ type: "open", hash }); return win.focus(); } return self.clients.openWindow(`./${hash}`); }), ); });