monal-rebuild/public/sw.js

162 lines
6.7 KiB
JavaScript

// 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 <message/> 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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
function attr(tag, name) {
const m = new RegExp(`\\s${name}=(['"])(.*?)\\1`).exec(tag);
return m ? unescape(m[2]) : "";
}
function parsePush(xml) {
const tag = /<message\b[^>]*>/.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 (/<propose\b[^>]*urn:xmpp:jingle-message:0/.test(xml)) return { peer, title: name, body: "Incoming call", call: true };
if (/<retract\b[^>]*urn:xmpp:jingle-message:0/.test(xml)) return { peer, title: name, body: "Missed call", call: true };
const text = /<body(?:\s[^>]*)?>([\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}`);
}),
);
});