Files
hub/public/sw.js
T
nmemmertandClaude Sonnet 5.5 54d18594a3 Accessibility settings, deferred scripts, and offline reading of tasks, notes, recipes, calendar and contacts
Keeps saved episodes and shares when the app updates (they were being deleted with the old shell cache).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:23:38 -04:00

102 lines
7.0 KiB
JavaScript

// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v111';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
// Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline.
async function precache() {
const c = await caches.open(CACHE);
await c.addAll(SHELL);
try {
const r = await fetch('/', { credentials: 'same-origin', redirect: 'manual' });
if (r.ok) {
const html = await r.clone().text();
await c.put('/', r);
const urls = [...html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css)(?:\?[^"]*)?)"/g)].map(m => m[1]);
await Promise.all(urls.map(u => c.add(u).catch(() => {})));
}
} catch { /* signed out or offline: it is cached the first time it is used */ }
}
self.addEventListener('install', e => { e.waitUntil(precache()); self.skipWaiting(); });
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE && !KEEP.test(k)).map(k => caches.delete(k))))); // (the app shell is replaced on a new version; saved episodes, shares and each person's saved data stay)
self.clients.claim();
});
// Podcast episodes saved on this device (see offline.js): answer from them, including the byte ranges an audio player asks for; otherwise use the network as normal.
async function offlineAudio(req) {
const hit = await (await caches.open('hub-offline')).match(req.url);
if (!hit) return fetch(req);
const blob = await hit.blob(), size = blob.size, type = hit.headers.get('content-type') || 'audio/mpeg', m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get('range') || '');
if (!m) return new Response(blob, { status: 200, headers: { 'content-type': type, 'content-length': size, 'accept-ranges': 'bytes' } });
let a = m[1] === '' ? Math.max(0, size - +m[2]) : +m[1], b = m[1] === '' || m[2] === '' ? size - 1 : Math.min(+m[2], size - 1);
if (a > b || a >= size) return new Response(null, { status: 416, headers: { 'content-range': 'bytes */' + size } });
return new Response(blob.slice(a, b + 1), { status: 206, headers: { 'content-type': type, 'content-length': b - a + 1, 'content-range': `bytes ${a}-${b}/${size}`, 'accept-ranges': 'bytes' } });
}
// Saved data for offline use: the lists a person is likely to want with no signal (tasks, notes, recipes, calendar, contacts, chores). Network first; when the network fails the last
// copy is used. Kept per person (the page tells us who is signed in), so a shared tablet never shows one person's data to another. Reading only: changes need a connection.
const DATA_OK = /^\/api\/(tasks(\/(upcoming|summary|lists|due))?|notes|recipes(\/(r\/[\w-]+|shopping))?|cal\/(events|list)|contacts|chores(\/\w+)?|bible\/(state))\/?$/;
const whoNow = async () => { try { const r = await (await caches.open('hub-who')).match('/who'); return r ? await r.text() : ''; } catch { return ''; } };
self.addEventListener('message', e => {
const d = e.data || {}; if (d.type !== 'who') return;
const id = String(d.id || '').replace(/[^\w-]/g, '');
e.waitUntil((async () => { await (await caches.open('hub-who')).put('/who', new Response(id)); for (const k of await caches.keys()) if (k.startsWith('hub-data-') && k !== 'hub-data-' + id) await caches.delete(k); })());
});
async function dataFirstNetwork(req) {
const who = await whoNow();
try {
const r = await fetch(req);
if (r.ok && who) { const copy = r.clone(); caches.open('hub-data-' + who).then(c => c.put(req.url, copy)); }
return r;
} catch (err) {
const hit = who && await (await caches.open('hub-data-' + who)).match(req.url);
if (hit) return hit;
throw err;
}
}
// Share target: other apps send files here (POST); keep them and open the app, which asks where to put them.
async function takeShare(request) {
try {
const form = await request.formData(), c = await caches.open(SHARE);
for (const k of await c.keys()) await c.delete(k);
let i = 0;
for (const f of form.getAll('files')) if (f && f.name) await c.put('/share-files/' + (i++), new Response(f, { headers: { 'x-name': encodeURIComponent(f.name), 'content-type': f.type || 'application/octet-stream' } }));
} catch { /* nothing usable was shared */ }
return Response.redirect(self.location.origin + '/#/files', 303);
}
self.addEventListener('fetch', e => {
const u = new URL(e.request.url);
if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/podcast\/podcasts\/[^/]+\/file$/.test(u.pathname)) return e.respondWith(offlineAudio(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/plex\/library\/parts\//.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); // (albums saved on this device)
if (e.request.method === 'GET' && u.origin === location.origin && DATA_OK.test(u.pathname)) return e.respondWith(dataFirstNetwork(e.request));
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
e.respondWith(fetch(e.request).then(r => {
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
return r;
}).catch(() => caches.match(e.request)));
});
// Push reminders (see push.js on the server)
self.addEventListener('push', e => {
let d = {}; try { d = e.data.json(); } catch {}
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } }));
});
// The push service can replace a device's subscription: make a new one and give it to the server so reminders keep arriving.
self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
try {
const k = await (await fetch('/api/push/key', { credentials: 'same-origin' })).json();
const s = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: k.publicKey });
await fetch('/api/push/subscribe', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON() }) });
} catch { /* signed out or offline: the app re-registers when it is next opened */ }
})()));
self.addEventListener('notificationclick', e => {
e.notification.close();
const url = '/' + ((e.notification.data && e.notification.data.url) || '');
e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(cs => {
const c = cs.find(x => new URL(x.url).origin === self.location.origin);
if (c) { c.focus(); return c.navigate(url); }
return self.clients.openWindow(url);
}));
});