From c9c24e7e2f5a7930ff58cd9aa4f28c41500f908b Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 5 Oct 2026 20:45:37 +0200 Subject: [PATCH] Offline access: self-updating worker, quiet console, setting in Accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The page asks the service worker for its version before using it, and has the browser fetch a newer one when it is out of date; the app also checks for a new sw.js on every start. An older worker left over from a previous release silently ignored the page's requests, so saving only resumed after offline access was switched off and on. - Fewer console errors offline: the server probe goes through the worker; fonts, the illustration, lazy chunks and the PDF worker (named inside the app's scripts and styles) are saved with the app; sender pictures are pre-loaded, and an unsaved one is "no picture" (204), not a 503; plugin bundles and plugin lookups are saved (per-message ones go with their message); POST lookups and live-update reconnects wait for the server to be back instead of failing over and over. - The setting moves from Settings → Security to Settings → Accounts. Co-Authored-By: Claude Opus 5.5 --- frontend/public/sw.js | 88 +++++++++++++++++----- frontend/src/components/Offline.tsx | 9 +-- frontend/src/i18n/en.ts | 2 +- frontend/src/lib/api.ts | 6 +- frontend/src/lib/offline.ts | 12 ++- frontend/src/lib/offlinePrefetch.ts | 89 ++++++++++++++++++++--- frontend/src/lib/ws.ts | 18 +++++ frontend/src/routes/settings/Settings.tsx | 6 +- 8 files changed, 190 insertions(+), 40 deletions(-) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 2288ad1..a4839db 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,4 +1,4 @@ -// Offline access (Settings → Security → "Keep recent mail on this device"). +// Offline access (Settings → Accounts → "Offline access"). // // Registered only on devices where the user turned it on. While the server // answers, everything comes from the network exactly as before; each answer is @@ -15,6 +15,11 @@ // Plain JavaScript, served as is from /sw.js: a service worker must live at a // fixed URL, so it cannot be a hashed Vite chunk. +// What the page expects this worker to understand (lib/offlinePrefetch.ts +// WORKER_VERSION): raise both together when the messages below change, so a +// page never talks to an older worker that would not answer. +const VERSION = 3; + const SHELL = "wm-shell-v1"; const DATA = "wm-data-v1"; const INDEX_KEY = "/__wm/offline-index"; @@ -24,6 +29,7 @@ const MAX_THREADS = 80; // the newest 50 Inbox conversations + recently opened o const MAX_ORPHANS = 50; // message parts whose conversation was never saved const MAX_LISTS = 40; // thread lists (one per folder / filter / page) const MAX_AVATARS = 300; +const MAX_PLUGIN = 60; // plugin lookups not tied to one message (the Labels list…) // How long to wait for the server before falling back to the saved copy. Long // enough that a slow connection still gets fresh data; a missing one fails at @@ -46,11 +52,19 @@ const META = new Set([ const ID = "[0-9a-fA-F-]{36}"; const THREAD_RE = new RegExp(`^/api/threads/(${ID})$`); const MESSAGE_RE = new RegExp(`^/api/messages/(${ID})/(?:html|attachments|cid/.+)$`); +const ID_IN_PATH = new RegExp(ID); +const PLUGIN_UI_RE = /^\/api\/plugins\/[^/]+\/ui\//; // plugin bundles (Labels' sidebar…) /** What a request is, for saving it; null = never saved (search, downloads, …). */ function classify(url) { const p = url.pathname; - if (META.has(p)) return { kind: "meta" }; + if (META.has(p) || PLUGIN_UI_RE.test(p)) return { kind: "meta" }; + if (p.startsWith("/api/plugins/")) { + // A plugin's lookup about one message (antispam's verdict…) goes with that + // message; anything else is kept a while, most recent first. + const id = p.match(ID_IN_PATH); + return id ? { kind: "message", id: id[0] } : { kind: "plugin" }; + } if (p === "/api/threads") return { kind: "list" }; if (p === "/api/avatar") return { kind: "avatar" }; let m = p.match(THREAD_RE); @@ -64,7 +78,7 @@ let offline = false; // ---- the index: what is saved, and when it was last fetched ------------------ -const emptyIndex = () => ({ user: null, threads: {}, msgs: {}, lists: {}, avatars: {} }); +const emptyIndex = () => ({ user: null, threads: {}, msgs: {}, lists: {}, avatars: {}, plugin: {} }); let index = null; let chain = Promise.resolve(); @@ -124,6 +138,8 @@ async function record(ix, url, cls, body) { ix.lists[url.pathname + url.search] = now; } else if (cls.kind === "avatar") { ix.avatars[url.pathname + url.search] = now; + } else if (cls.kind === "plugin") { + (ix.plugin ??= {})[url.pathname + url.search] = now; } return trim(ix); } @@ -145,6 +161,7 @@ async function trim(ix) { for (const [saved, max] of [ [ix.lists, MAX_LISTS], [ix.avatars, MAX_AVATARS], + [(ix.plugin ??= {}), MAX_PLUGIN], ]) { const all = oldestFirst(saved); for (const [u] of all.slice(0, Math.max(0, all.length - max))) { @@ -161,7 +178,8 @@ async function trim(ix) { const u = new URL(req.url); const p = u.pathname; const t = p.match(THREAD_RE); - const m = p.match(MESSAGE_RE); + // Message parts, and plugin lookups about a message (see classify). + const m = p.match(MESSAGE_RE) ?? (p.startsWith("/api/plugins/") ? p.match(new RegExp(`(${ID})`)) : null); if ( dropUrls.has(p + u.search) || (t && dropThreads.has(t[1])) || @@ -174,6 +192,11 @@ async function trim(ix) { // ---- saving and serving ------------------------------------------------------ +/** A full answer — or, for a sender picture, the server's "none" (204). */ +function worthSaving(res, cls) { + return res.status === 200 || (cls.kind === "avatar" && res.status === 204); +} + /** Save a copy of a server answer (`res` is a clone the page never reads). */ async function save(req, res, cls) { const url = new URL(req.url); @@ -198,7 +221,8 @@ async function save(req, res, cls) { const headers = new Headers(res.headers); headers.set("X-WM-Saved-At", String(Date.now())); const cache = await caches.open(DATA); - await cache.put(url.href, new Response(body, { status: 200, headers })); + const empty = res.status === 204; + await cache.put(url.href, new Response(empty ? null : body, { status: res.status, headers })); if (cls.kind !== "meta") await withIndex((ix) => record(ix, url, cls, body)); } @@ -218,7 +242,9 @@ function fromSaved(hit) { return new Response(hit.body, { status: hit.status, statusText: hit.statusText, headers }); } -function notSaved() { +function notSaved(cls) { + // A sender picture not saved: "no picture" (the initials show), not an error. + if (cls?.kind === "avatar") return new Response(null, { status: 204, headers: { "X-WM-Offline": "1" } }); return new Response( JSON.stringify({ error: { code: "offline", message: "Not available offline" } }), { status: 503, headers: { "Content-Type": "application/json", "X-WM-Offline": "1" } }, @@ -252,7 +278,7 @@ async function fromServer(event, req, cls, ms) { offline = false; if (res.status === 401) event.waitUntil(purge()); // Copied now, before the page starts reading the original. - else if (cls && res.status === 200) event.waitUntil(save(req, res.clone(), cls).catch(() => {})); + else if (cls && worthSaving(res, cls)) event.waitUntil(save(req, res.clone(), cls).catch(() => {})); return res; } @@ -264,7 +290,7 @@ async function apiData(event, req, cls) { try { return await fromServer(event, req, cls, RETRY_TIMEOUT); } catch { - return notSaved(); + return notSaved(cls); } } try { @@ -272,7 +298,7 @@ async function apiData(event, req, cls) { } catch { offline = true; const hit = await lookup(req, cls); - return hit ? fromSaved(hit) : notSaved(); + return hit ? fromSaved(hit) : notSaved(cls); } } @@ -300,19 +326,44 @@ function referencedAssets(html) { return [...out]; } +// Build files named inside the app's scripts and styles: lazy chunks, fonts, +// images, the PDF worker. Not in the page itself, but needed offline all the same. +const NESTED_RE = /(?:^|[^\w/])\/?(assets\/[\w.-]+\.(?:js|mjs|css|svg|png|jpe?g|webp|ttf|otf|woff2?))/g; + +async function withNested(files) { + const all = new Set(files); + const cache = await caches.open(SHELL); + for (const p of files) { + if (!/\.(?:js|css)$/.test(p)) continue; + try { + // Read from the saved copy when there is one: this runs on every app load. + const r = (await cache.match(p)) ?? (await fetch(p)); + if (!r.ok) continue; + for (const m of (await r.text()).matchAll(NESTED_RE)) all.add("/" + m[1]); + } catch { + /* next time */ + } + } + return [...all]; +} + /** Keep the app page, and everything it loads, for opening without a connection. */ async function saveApp(res) { const html = await res.text(); const cache = await caches.open(SHELL); const previous = await cache.match(APP_KEY); - if (previous && (await previous.text()) === html) return; - await cache.put(APP_KEY, new Response(html, { headers: { "Content-Type": "text/html" } })); - const wanted = referencedAssets(html); - // A new release: its files have new names, so the old ones can go. Files - // loaded later (lazy chunks) are saved again the first time they are used. - for (const req of await cache.keys()) { - const p = new URL(req.url).pathname; - if (p.startsWith("/assets/") && !wanted.includes(p)) await cache.delete(req); + const release = !previous || (await previous.text()) !== html; + if (release) await cache.put(APP_KEY, new Response(html, { headers: { "Content-Type": "text/html" } })); + // Every time, not only for a new release: an older worker may have saved the + // page without everything it needs (missing files are fetched, the rest is + // read from the saved copies). + const wanted = await withNested(referencedAssets(html)); + // A new release: its files have new names, so the old ones can go. + if (release) { + for (const req of await cache.keys()) { + const p = new URL(req.url).pathname; + if (p.startsWith("/assets/") && !wanted.includes(p)) await cache.delete(req); + } } await Promise.all( wanted.map(async (p) => { @@ -391,7 +442,7 @@ async function fetchForPage(url, port) { const res = await network(req, API_TIMEOUT); offline = false; if (res.status === 401) await purge(); - else if (cls && res.status === 200) await save(req, res.clone(), cls); + else if (cls && worthSaving(res, cls)) await save(req, res.clone(), cls); port.postMessage({ status: res.status, body: await res.text() }); } catch { offline = true; @@ -401,6 +452,7 @@ async function fetchForPage(url, port) { self.addEventListener("message", (event) => { if (event.data?.type === "purge") event.waitUntil(purge()); + if (event.data?.type === "version" && event.ports[0]) event.ports[0].postMessage({ version: VERSION }); if (event.data?.type === "fetch" && event.ports[0]) { event.waitUntil(fetchForPage(event.data.url, event.ports[0])); } diff --git a/frontend/src/components/Offline.tsx b/frontend/src/components/Offline.tsx index 9afa293..d273299 100644 --- a/frontend/src/components/Offline.tsx +++ b/frontend/src/components/Offline.tsx @@ -19,7 +19,7 @@ import { useOffline, useOfflineEnabled, } from "../lib/offline"; -import { prefetchRecent } from "../lib/offlinePrefetch"; +import { prefetchRecent, serverAnswers } from "../lib/offlinePrefetch"; import { subscribeWs } from "../lib/ws"; const PROBE_MS = 10_000; @@ -111,12 +111,7 @@ export function OfflineController() { useEffect(() => { if (!offline) return; const probe = async () => { - try { - const resp = await fetch("/api/healthz", { cache: "no-store", credentials: "include" }); - if (!resp.headers.has("X-WM-Offline")) reportOnline(); - } catch { - /* still offline */ - } + if (await serverAnswers()) reportOnline(); }; const timer = window.setInterval(probe, PROBE_MS); window.addEventListener("online", probe); diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 26dbad7..6dcb897 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -550,7 +550,7 @@ export const en = { "offline.explainSaved": "Offline — showing mail saved on this device, last updated {when}.", "offline.explainSavedNoTime": "Offline — showing mail saved on this device.", "offline.explainNotSaved": - "Can't reach the server. To read recent mail without a connection, turn on offline access in Settings → Security.", + "Can't reach the server. To read recent mail without a connection, turn on offline access in Settings → Accounts.", "offline.noticeSaved": "You're offline. Recent mail is still available.", "offline.noticeNotSaved": "You're offline — can't reach the server.", "offline.notAvailable": "Not available offline", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2c6da9a..f527676 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -32,7 +32,7 @@ import type { UserSettings, } from "./types"; import type { UiManifest } from "../plugins/types"; -import { noteResponse, reportOffline } from "./offline"; +import { isOffline, noteResponse, reportOffline } from "./offline"; /** The request failed only because the server could not be reached. */ export function isOfflineError(error: unknown): boolean { @@ -71,6 +71,10 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise headers.set("Content-Type", "application/json"); } } + // Known to be offline: a change, or a lookup sent as POST, cannot be saved + // or served from the saved copy — fail at once, without a doomed request + // (and its error line in the console). The offline probe notices the return. + if (method !== "GET" && isOffline()) throw new ApiError(0, "offline", "Not available offline"); let resp: Response; try { resp = await fetch(`/api${path}`, { diff --git a/frontend/src/lib/offline.ts b/frontend/src/lib/offline.ts index 819e2b0..56a8150 100644 --- a/frontend/src/lib/offline.ts +++ b/frontend/src/lib/offline.ts @@ -48,6 +48,13 @@ export function reportOnline(): void { emit(); } +/** Call `fn` when the server is reachable again; returns an unsubscribe. */ +export function onOnline(fn: () => void): () => void { + return subscribe(() => { + if (!offline) fn(); + }); +} + export function isOffline(): boolean { return offline; } @@ -131,7 +138,10 @@ export async function startOffline(): Promise { if (!("serviceWorker" in navigator)) return; if (offlineEnabled() && offlineSupported()) { try { - await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + // register() does not look for a newer sw.js when one is installed + // already; ask explicitly, so a release reaches this device on its next start. + void reg.update().catch(() => {}); } catch (err) { console.warn("offline access: service worker not installed", err); } diff --git a/frontend/src/lib/offlinePrefetch.ts b/frontend/src/lib/offlinePrefetch.ts index e94fab8..a695299 100644 --- a/frontend/src/lib/offlinePrefetch.ts +++ b/frontend/src/lib/offlinePrefetch.ts @@ -11,12 +11,15 @@ import { threadPath, threadsPath } from "./api"; import { isOffline, isSaved, offlineReady, reportOffline, setLastSync } from "./offline"; +import type { UiManifest } from "../plugins/types"; import type { MessageEnvelope, ThreadDetail, ThreadListOut, UserSettings } from "./types"; const INBOX = "folder:inbox"; const COUNT = 50; // the list's page size, so the first page is saved as shown const PARALLEL = 3; const TIMEOUT_MS = 30_000; +// The message protocol this page speaks with public/sw.js (its VERSION). +const WORKER_VERSION = 3; const CID_RE = /\/api\/messages\/[0-9a-fA-F-]{36}\/cid\/[^"'\s)&]+/g; // What the app asks for when it starts: without these it cannot open offline. @@ -27,27 +30,59 @@ const STARTUP = [ "/mailboxes/counts", "/settings/quota", "/saved-searches", - "/plugins/ui-manifest", ]; let running: Promise | null = null; class Unreachable extends Error {} -/** Fetch through the service worker, which saves the answer; returns its text. */ -async function viaWorker(url: string): Promise { - const reg = await navigator.serviceWorker.ready; - const worker = reg.active; - if (!worker) throw new Unreachable("no service worker"); - const reply = await new Promise<{ status: number; body?: string }>((resolve, reject) => { +function ask(worker: ServiceWorker, message: object, ms: number): Promise { + return new Promise((resolve, reject) => { const channel = new MessageChannel(); - const timer = window.setTimeout(() => reject(new Unreachable("timeout")), TIMEOUT_MS); + const timer = window.setTimeout(() => reject(new Unreachable("no answer")), ms); channel.port1.onmessage = (e) => { window.clearTimeout(timer); - resolve(e.data); + resolve(e.data as T); }; - worker.postMessage({ type: "fetch", url }, [channel.port2]); + worker.postMessage(message, [channel.port2]); }); +} + +async function upToDate(worker: ServiceWorker | null): Promise { + if (!worker) return false; + try { + const { version } = await ask<{ version?: number }>(worker, { type: "version" }, 2000); + return (version ?? 0) >= WORKER_VERSION; + } catch { + return false; // too old to know the question + } +} + +let checked: ServiceWorker | null = null; + +/** The running service worker — updated first if it is an older release. */ +async function currentWorker(): Promise { + const reg = await navigator.serviceWorker.ready; + if (reg.active && reg.active === checked) return reg.active; + if (await upToDate(reg.active)) { + checked = reg.active; + return reg.active!; + } + await reg.update().catch(() => {}); + for (let i = 0; i < 30; i++) { + await new Promise((r) => window.setTimeout(r, 500)); + if (reg.active && (await upToDate(reg.active))) { + checked = reg.active; + return reg.active; + } + } + throw new Unreachable("the offline service worker could not be updated"); +} + +/** Fetch through the service worker, which saves the answer; returns its text. */ +async function viaWorker(url: string): Promise { + const worker = await currentWorker(); + const reply = await ask<{ status: number; body?: string }>(worker, { type: "fetch", url }, TIMEOUT_MS); if (reply.status === 0) { reportOffline(); throw new Unreachable("offline"); @@ -77,7 +112,15 @@ async function inPool(items: T[], n: number, fn: (item: T) => Promise): await Promise.all(Array.from({ length: n }, worker)); } +/** A sender picture, at the URL AvatarImage asks for (204 = none, saved too). */ +async function saveAvatar(email: string | undefined | null): Promise { + if (!email || !email.includes("@")) return; + const url = `/api/avatar?email=${encodeURIComponent(email)}`; + if (!(await isSaved(url))) await viaWorker(url); +} + async function saveMessage(m: MessageEnvelope): Promise { + await saveAvatar(m.from_addr[0]?.email); const htmlUrl = `/api/messages/${m.id}/html${m.images_allowed ? "?images=1" : ""}`; if (!(await isSaved(htmlUrl))) { const html = await viaWorker(htmlUrl); @@ -92,6 +135,10 @@ async function saveMessage(m: MessageEnvelope): Promise { async function run(): Promise { if (!(await offlineReady()) || isOffline()) return; for (const path of STARTUP) await viaWorker(`/api${path}`); + // Plugin scripts the mail view mounts (the Labels sidebar…). + const manifest = await getJson("/plugins/ui-manifest"); + const bundles = new Set(manifest.plugins.flatMap((pl) => [...pl.slots, ...pl.pages].map((d) => d.bundle))); + for (const url of bundles) if (!(await isSaved(url))) await viaWorker(url); const settings = await getJson("/settings"); const order = settings.thread_order ?? "asc"; // The list as the app shows it (newest first is the default), plus the other @@ -102,6 +149,7 @@ async function run(): Promise { } await inPool(list.threads, PARALLEL, async (summary) => { + for (const who of summary.participants) await saveAvatar(who.email); const detail = await getJson(threadPath(summary.id, order, INBOX)); for (const m of detail.messages) await saveMessage(m); }); @@ -117,3 +165,24 @@ export function prefetchRecent(): Promise { }); return running; } + +/** + * Whether the server answers again (the probe while offline). Through the + * service worker when there is one, so the failed tries do not fill the + * browser console with errors; a plain request otherwise. + */ +export async function serverAnswers(): Promise { + if (await offlineReady()) { + try { + return (await viaWorker("/api/healthz")) !== null; + } catch { + return false; + } + } + try { + const resp = await fetch("/api/healthz", { cache: "no-store", credentials: "include" }); + return !resp.headers.has("X-WM-Offline"); + } catch { + return false; + } +} diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts index 28de199..a2e2e07 100644 --- a/frontend/src/lib/ws.ts +++ b/frontend/src/lib/ws.ts @@ -5,6 +5,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { z } from "zod"; import { signedOut } from "./auth"; +import { isOffline, onOnline } from "./offline"; const frameSchema = z.object({ event: z.string(), @@ -142,6 +143,12 @@ export function connectWs(queryClient: QueryClient): () => void { socket.onclose = () => { if (ping !== undefined) window.clearInterval(ping); if (stopped) return; + if (isOffline()) { + // Offline: wait for the probe to see the server again rather than + // retry blindly (each failed try is an error line in the console). + waitOnline(); + return; + } // Exponential backoff, capped at 30s, so a downed server is retried politely. const delay = Math.min(30000, 1000 * 2 ** attempts); attempts += 1; @@ -150,10 +157,21 @@ export function connectWs(queryClient: QueryClient): () => void { socket.onerror = () => socket?.close(); // route errors through onclose -> reconnect }; + let stopWaiting: (() => void) | null = null; + const waitOnline = () => { + stopWaiting ??= onOnline(() => { + stopWaiting?.(); + stopWaiting = null; + attempts = 0; + if (!stopped) connect(); + }); + }; + connect(); return () => { stopped = true; + stopWaiting?.(); if (ping !== undefined) window.clearInterval(ping); if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer); socket?.close(); diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index bfbecfd..ec4f02c 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -227,7 +227,7 @@ function formatMB(bytes: number): string { return bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`; } -/** Settings → Security: keep recent mail on this device for reading offline. */ +/** Settings → Accounts: keep recent mail on this device for reading offline. */ function OfflineAccessSetting() { const t = useT(); const enabled = useOfflineEnabled(); @@ -439,7 +439,6 @@ function SecuritySection() { ))} - ); } @@ -546,6 +545,9 @@ function AccountsSection() { ))} +
+ +
{deletingId && (