1// Service worker: makes the relay installable and keeps navigation working2// when the network fails. Every request goes to the network first. Successful3// page loads and the shell scripts are kept as the offline fallback; relay4// data requested by scripts is never cached.5const CACHE = "tiny-shell-v1";6const shellAsset = url => /\/(fixi\.js|signer\.js|webmcp\.js|sw\.js|scripts\/[\w.-]+\.js|icon[^/]*\.(svg|png)|badge-96\.png|apple-touch-icon\.png|manifest\.webmanifest)$/.test(url.pathname);7self.addEventListener("install", () => self.skipWaiting());8self.addEventListener("activate", event => {9 event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()));10});11self.addEventListener("fetch", event => {12 const request = event.request;13 const url = new URL(request.url);14 if (url.origin !== location.origin) return;15 if (request.method === "POST" && /\/share$/.test(url.pathname)) {16 event.respondWith(receiveShare(request, url));17 return;18 }19 if (request.method !== "GET") return;20 const keep = request.mode === "navigate" || shellAsset(url);21 if (!keep) return;22 event.respondWith(fetch(request).then(response => {23 if (response.ok && response.type === "basic") {24 const copy = response.clone();25 caches.open(CACHE).then(cache => cache.put(request, copy)).catch(() => {});26 }27 return response;28 }).catch(async () => (await caches.match(request)) || (request.mode === "navigate" ? caches.match(new Request(url.origin + url.pathname.replace(/\/[^/]*$/, "/"))) : undefined)));29});3031// Shares from other apps arrive as a form post. The files are parked in a32// cache under a fresh id and the Files page picks them up from its fragment,33// so nothing is uploaded until the person presses Upload there.34const receiveShare = async (request, url) => {35 const form = await request.formData();36 const id = crypto.randomUUID();37 const cache = await caches.open("tiny-share");38 const files = form.getAll("files").filter(item => typeof item === "object" && "arrayBuffer" in item);39 await Promise.all(files.map((file, index) => cache.put(new Request(url.origin + "/share/" + id + "/" + index), new Response(file, {headers: {"content-type": file.type || "application/octet-stream", "x-name": encodeURIComponent(file.name || "shared")}}))));40 await cache.put(new Request(url.origin + "/share/" + id + "/meta"), Response.json({title: form.get("title") || "", text: form.get("text") || "", url: form.get("url") || "", count: files.length}));41 return Response.redirect(url.pathname.replace(/\/share$/, "/files") + "#share=" + id, 303);42};4344// Background uploads finish after the page closes. The last response is the45// blob descriptor; it is parked for the Files page and the person is told.46const uploadResult = async (registration, ok, detail) => {47 const cache = await caches.open("tiny-uploads");48 let response = Response.json({error: detail || "upload failed"}, {status: 500});49 if (ok) {50 const records = await registration.matchAll();51 const last = records[records.length - 1];52 if (last) response = (await last.responseReady).clone();53 }54 await cache.put(new Request(new URL("/uploads/" + registration.id, self.registration.scope).href), response);55};56self.addEventListener("backgroundfetchsuccess", event => {57 event.waitUntil(uploadResult(event.registration, true).then(() => event.updateUI({title: "Upload complete"})));58});59self.addEventListener("backgroundfetchfail", event => {60 event.waitUntil(uploadResult(event.registration, false, event.registration.failureReason).then(() => event.updateUI({title: "Upload failed"})));61});62self.addEventListener("backgroundfetchabort", event => {63 event.waitUntil(uploadResult(event.registration, false, "aborted"));64});65self.addEventListener("backgroundfetchclick", event => {66 event.waitUntil(self.clients.openWindow(new URL("files", self.registration.scope).href));67});6869// Notifications are opt-in per device; a push only arrives after the person70// enabled them on this browser. The payload is the relay's short summary,71// with the answers a request for a decision offers as notification actions.72const notificationActions = list => Array.isArray(list) ? list.filter(item => item && /^[a-z]+$/.test(item.action || "")).map(item => ({action: item.action, title: String(item.title || item.action)})) : [];73self.addEventListener("push", event => {74 let data = {};75 try { data = event.data?.json() || {}; } catch { data = {body: event.data?.text() || ""}; }76 const base = new URL(self.registration.scope);77 const badge = Number.isInteger(data.badge) && data.badge > 0 ? navigator.setAppBadge?.(data.badge) : navigator.clearAppBadge?.();78 event.waitUntil(Promise.all([Promise.resolve(badge).catch(() => {}), self.registration.showNotification(data.title || "tiny", {79 body: data.body || "", tag: data.tag || "tiny", icon: new URL("icon-192.png", base).href, badge: new URL("badge-96.png", base).href, data: {url: data.url || base.href}, actions: notificationActions(data.actions)80 })]));81});82// An action opens the notification's page with answer=<action>, where the83// page asks for confirmation before anything is signed.84const answerURL = (url, action) => /^[a-z]+$/.test(action || "") ? url + (url.includes("?") ? "&" : "?") + "answer=" + action : url;85self.addEventListener("notificationclick", event => {86 event.notification.close();87 const target = answerURL(event.notification.data?.url || self.registration.scope, event.action);88 event.waitUntil(self.clients.matchAll({type: "window", includeUncontrolled: true}).then(async list => {89 const open = list.find(client => client.url.startsWith(self.registration.scope));90 if (open) { const focused = await open.navigate(target); return focused?.focus(); }91 return self.clients.openWindow(target);92 }));93});