162 lines
6.7 KiB
JavaScript
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(/</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 = /<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}`);
|
|
}),
|
|
);
|
|
});
|