Offline access: self-updating worker, quiet console, setting in Accounts
- 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 <noreply@anthropic.com>
This commit is contained in:
+70
-18
@@ -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]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user