| 1 | // Service worker: makes the relay installable and keeps navigation working |
| 2 | // when the network fails. Every request goes to the network first. Successful |
| 3 | // page loads and the shell scripts are kept as the offline fallback; relay |
| 4 | // data requested by scripts is never cached. |
| 5 | const CACHE = "tiny-shell-v1" ; |
| 6 | const 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); |
| 7 | self.addEventListener("install" , () => self.skipWaiting()); |
| 8 | self.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 | }); |
| 11 | self.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 | }); |
| 30 | |
| 31 | // Shares from other apps arrive as a form post. The files are parked in a |
| 32 | // 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. |
| 34 | const 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 | }; |
| 43 | |
| 44 | // Background uploads finish after the page closes. The last response is the |
| 45 | // blob descriptor; it is parked for the Files page and the person is told. |
| 46 | const 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 | }; |
| 56 | self.addEventListener("backgroundfetchsuccess" , event => { |
| 57 | event.waitUntil(uploadResult(event.registration, true).then(() => event.updateUI({title: "Upload complete" }))); |
| 58 | }); |
| 59 | self.addEventListener("backgroundfetchfail" , event => { |
| 60 | event.waitUntil(uploadResult(event.registration, false, event.registration.failureReason).then(() => event.updateUI({title: "Upload failed" }))); |
| 61 | }); |
| 62 | self.addEventListener("backgroundfetchabort" , event => { |
| 63 | event.waitUntil(uploadResult(event.registration, false, "aborted" )); |
| 64 | }); |
| 65 | self.addEventListener("backgroundfetchclick" , event => { |
| 66 | event.waitUntil(self.clients.openWindow(new URL("files" , self.registration.scope).href)); |
| 67 | }); |
| 68 | |
| 69 | // Notifications are opt-in per device; a push only arrives after the person |
| 70 | // 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. |
| 72 | const 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)})) : []; |
| 73 | self.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 the |
| 83 | // page asks for confirmation before anything is signed. |
| 84 | const answerURL = (url, action) => /^[a-z]+$/.test(action || "" ) ? url + (url.includes("?" ) ? "&" : "?" ) + "answer=" + action : url; |
| 85 | self.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 | }); |