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:
2026-10-05 20:45:37 +02:00
co-authored by Claude Opus 5.5
parent 01bfbb37d4
commit c9c24e7e2f
8 changed files with 190 additions and 40 deletions
+67 -15
View File
@@ -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 // 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 // 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 // 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. // 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 SHELL = "wm-shell-v1";
const DATA = "wm-data-v1"; const DATA = "wm-data-v1";
const INDEX_KEY = "/__wm/offline-index"; 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_ORPHANS = 50; // message parts whose conversation was never saved
const MAX_LISTS = 40; // thread lists (one per folder / filter / page) const MAX_LISTS = 40; // thread lists (one per folder / filter / page)
const MAX_AVATARS = 300; 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 // 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 // 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 ID = "[0-9a-fA-F-]{36}";
const THREAD_RE = new RegExp(`^/api/threads/(${ID})$`); const THREAD_RE = new RegExp(`^/api/threads/(${ID})$`);
const MESSAGE_RE = new RegExp(`^/api/messages/(${ID})/(?:html|attachments|cid/.+)$`); 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, …). */ /** What a request is, for saving it; null = never saved (search, downloads, …). */
function classify(url) { function classify(url) {
const p = url.pathname; 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/threads") return { kind: "list" };
if (p === "/api/avatar") return { kind: "avatar" }; if (p === "/api/avatar") return { kind: "avatar" };
let m = p.match(THREAD_RE); let m = p.match(THREAD_RE);
@@ -64,7 +78,7 @@ let offline = false;
// ---- the index: what is saved, and when it was last fetched ------------------ // ---- 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 index = null;
let chain = Promise.resolve(); let chain = Promise.resolve();
@@ -124,6 +138,8 @@ async function record(ix, url, cls, body) {
ix.lists[url.pathname + url.search] = now; ix.lists[url.pathname + url.search] = now;
} else if (cls.kind === "avatar") { } else if (cls.kind === "avatar") {
ix.avatars[url.pathname + url.search] = now; ix.avatars[url.pathname + url.search] = now;
} else if (cls.kind === "plugin") {
(ix.plugin ??= {})[url.pathname + url.search] = now;
} }
return trim(ix); return trim(ix);
} }
@@ -145,6 +161,7 @@ async function trim(ix) {
for (const [saved, max] of [ for (const [saved, max] of [
[ix.lists, MAX_LISTS], [ix.lists, MAX_LISTS],
[ix.avatars, MAX_AVATARS], [ix.avatars, MAX_AVATARS],
[(ix.plugin ??= {}), MAX_PLUGIN],
]) { ]) {
const all = oldestFirst(saved); const all = oldestFirst(saved);
for (const [u] of all.slice(0, Math.max(0, all.length - max))) { 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 u = new URL(req.url);
const p = u.pathname; const p = u.pathname;
const t = p.match(THREAD_RE); 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 ( if (
dropUrls.has(p + u.search) || dropUrls.has(p + u.search) ||
(t && dropThreads.has(t[1])) || (t && dropThreads.has(t[1])) ||
@@ -174,6 +192,11 @@ async function trim(ix) {
// ---- saving and serving ------------------------------------------------------ // ---- 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). */ /** Save a copy of a server answer (`res` is a clone the page never reads). */
async function save(req, res, cls) { async function save(req, res, cls) {
const url = new URL(req.url); const url = new URL(req.url);
@@ -198,7 +221,8 @@ async function save(req, res, cls) {
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
headers.set("X-WM-Saved-At", String(Date.now())); headers.set("X-WM-Saved-At", String(Date.now()));
const cache = await caches.open(DATA); 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)); 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 }); 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( return new Response(
JSON.stringify({ error: { code: "offline", message: "Not available offline" } }), JSON.stringify({ error: { code: "offline", message: "Not available offline" } }),
{ status: 503, headers: { "Content-Type": "application/json", "X-WM-Offline": "1" } }, { status: 503, headers: { "Content-Type": "application/json", "X-WM-Offline": "1" } },
@@ -252,7 +278,7 @@ async function fromServer(event, req, cls, ms) {
offline = false; offline = false;
if (res.status === 401) event.waitUntil(purge()); if (res.status === 401) event.waitUntil(purge());
// Copied now, before the page starts reading the original. // 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; return res;
} }
@@ -264,7 +290,7 @@ async function apiData(event, req, cls) {
try { try {
return await fromServer(event, req, cls, RETRY_TIMEOUT); return await fromServer(event, req, cls, RETRY_TIMEOUT);
} catch { } catch {
return notSaved(); return notSaved(cls);
} }
} }
try { try {
@@ -272,7 +298,7 @@ async function apiData(event, req, cls) {
} catch { } catch {
offline = true; offline = true;
const hit = await lookup(req, cls); const hit = await lookup(req, cls);
return hit ? fromSaved(hit) : notSaved(); return hit ? fromSaved(hit) : notSaved(cls);
} }
} }
@@ -300,20 +326,45 @@ function referencedAssets(html) {
return [...out]; 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. */ /** Keep the app page, and everything it loads, for opening without a connection. */
async function saveApp(res) { async function saveApp(res) {
const html = await res.text(); const html = await res.text();
const cache = await caches.open(SHELL); const cache = await caches.open(SHELL);
const previous = await cache.match(APP_KEY); const previous = await cache.match(APP_KEY);
if (previous && (await previous.text()) === html) return; const release = !previous || (await previous.text()) !== html;
await cache.put(APP_KEY, new Response(html, { headers: { "Content-Type": "text/html" } })); if (release) await cache.put(APP_KEY, new Response(html, { headers: { "Content-Type": "text/html" } }));
const wanted = referencedAssets(html); // Every time, not only for a new release: an older worker may have saved the
// A new release: its files have new names, so the old ones can go. Files // page without everything it needs (missing files are fetched, the rest is
// loaded later (lazy chunks) are saved again the first time they are used. // 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()) { for (const req of await cache.keys()) {
const p = new URL(req.url).pathname; const p = new URL(req.url).pathname;
if (p.startsWith("/assets/") && !wanted.includes(p)) await cache.delete(req); if (p.startsWith("/assets/") && !wanted.includes(p)) await cache.delete(req);
} }
}
await Promise.all( await Promise.all(
wanted.map(async (p) => { wanted.map(async (p) => {
if (await cache.match(p)) return; if (await cache.match(p)) return;
@@ -391,7 +442,7 @@ async function fetchForPage(url, port) {
const res = await network(req, API_TIMEOUT); const res = await network(req, API_TIMEOUT);
offline = false; offline = false;
if (res.status === 401) await purge(); 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() }); port.postMessage({ status: res.status, body: await res.text() });
} catch { } catch {
offline = true; offline = true;
@@ -401,6 +452,7 @@ async function fetchForPage(url, port) {
self.addEventListener("message", (event) => { self.addEventListener("message", (event) => {
if (event.data?.type === "purge") event.waitUntil(purge()); 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]) { if (event.data?.type === "fetch" && event.ports[0]) {
event.waitUntil(fetchForPage(event.data.url, event.ports[0])); event.waitUntil(fetchForPage(event.data.url, event.ports[0]));
} }
+2 -7
View File
@@ -19,7 +19,7 @@ import {
useOffline, useOffline,
useOfflineEnabled, useOfflineEnabled,
} from "../lib/offline"; } from "../lib/offline";
import { prefetchRecent } from "../lib/offlinePrefetch"; import { prefetchRecent, serverAnswers } from "../lib/offlinePrefetch";
import { subscribeWs } from "../lib/ws"; import { subscribeWs } from "../lib/ws";
const PROBE_MS = 10_000; const PROBE_MS = 10_000;
@@ -111,12 +111,7 @@ export function OfflineController() {
useEffect(() => { useEffect(() => {
if (!offline) return; if (!offline) return;
const probe = async () => { const probe = async () => {
try { if (await serverAnswers()) reportOnline();
const resp = await fetch("/api/healthz", { cache: "no-store", credentials: "include" });
if (!resp.headers.has("X-WM-Offline")) reportOnline();
} catch {
/* still offline */
}
}; };
const timer = window.setInterval(probe, PROBE_MS); const timer = window.setInterval(probe, PROBE_MS);
window.addEventListener("online", probe); window.addEventListener("online", probe);
+1 -1
View File
@@ -550,7 +550,7 @@ export const en = {
"offline.explainSaved": "Offline — showing mail saved on this device, last updated {when}.", "offline.explainSaved": "Offline — showing mail saved on this device, last updated {when}.",
"offline.explainSavedNoTime": "Offline — showing mail saved on this device.", "offline.explainSavedNoTime": "Offline — showing mail saved on this device.",
"offline.explainNotSaved": "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.noticeSaved": "You're offline. Recent mail is still available.",
"offline.noticeNotSaved": "You're offline — can't reach the server.", "offline.noticeNotSaved": "You're offline — can't reach the server.",
"offline.notAvailable": "Not available offline", "offline.notAvailable": "Not available offline",
+5 -1
View File
@@ -32,7 +32,7 @@ import type {
UserSettings, UserSettings,
} from "./types"; } from "./types";
import type { UiManifest } from "../plugins/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. */ /** The request failed only because the server could not be reached. */
export function isOfflineError(error: unknown): boolean { export function isOfflineError(error: unknown): boolean {
@@ -71,6 +71,10 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
headers.set("Content-Type", "application/json"); 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; let resp: Response;
try { try {
resp = await fetch(`/api${path}`, { resp = await fetch(`/api${path}`, {
+11 -1
View File
@@ -48,6 +48,13 @@ export function reportOnline(): void {
emit(); 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 { export function isOffline(): boolean {
return offline; return offline;
} }
@@ -131,7 +138,10 @@ export async function startOffline(): Promise<void> {
if (!("serviceWorker" in navigator)) return; if (!("serviceWorker" in navigator)) return;
if (offlineEnabled() && offlineSupported()) { if (offlineEnabled() && offlineSupported()) {
try { 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) { } catch (err) {
console.warn("offline access: service worker not installed", err); console.warn("offline access: service worker not installed", err);
} }
+79 -10
View File
@@ -11,12 +11,15 @@
import { threadPath, threadsPath } from "./api"; import { threadPath, threadsPath } from "./api";
import { isOffline, isSaved, offlineReady, reportOffline, setLastSync } from "./offline"; import { isOffline, isSaved, offlineReady, reportOffline, setLastSync } from "./offline";
import type { UiManifest } from "../plugins/types";
import type { MessageEnvelope, ThreadDetail, ThreadListOut, UserSettings } from "./types"; import type { MessageEnvelope, ThreadDetail, ThreadListOut, UserSettings } from "./types";
const INBOX = "folder:inbox"; const INBOX = "folder:inbox";
const COUNT = 50; // the list's page size, so the first page is saved as shown const COUNT = 50; // the list's page size, so the first page is saved as shown
const PARALLEL = 3; const PARALLEL = 3;
const TIMEOUT_MS = 30_000; 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; 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. // What the app asks for when it starts: without these it cannot open offline.
@@ -27,27 +30,59 @@ const STARTUP = [
"/mailboxes/counts", "/mailboxes/counts",
"/settings/quota", "/settings/quota",
"/saved-searches", "/saved-searches",
"/plugins/ui-manifest",
]; ];
let running: Promise<void> | null = null; let running: Promise<void> | null = null;
class Unreachable extends Error {} class Unreachable extends Error {}
/** Fetch through the service worker, which saves the answer; returns its text. */ function ask<T>(worker: ServiceWorker, message: object, ms: number): Promise<T> {
async function viaWorker(url: string): Promise<string | null> { return new Promise<T>((resolve, reject) => {
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) => {
const channel = new MessageChannel(); 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) => { channel.port1.onmessage = (e) => {
window.clearTimeout(timer); 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<boolean> {
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<ServiceWorker> {
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<string | null> {
const worker = await currentWorker();
const reply = await ask<{ status: number; body?: string }>(worker, { type: "fetch", url }, TIMEOUT_MS);
if (reply.status === 0) { if (reply.status === 0) {
reportOffline(); reportOffline();
throw new Unreachable("offline"); throw new Unreachable("offline");
@@ -77,7 +112,15 @@ async function inPool<T>(items: T[], n: number, fn: (item: T) => Promise<void>):
await Promise.all(Array.from({ length: n }, worker)); 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<void> {
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<void> { async function saveMessage(m: MessageEnvelope): Promise<void> {
await saveAvatar(m.from_addr[0]?.email);
const htmlUrl = `/api/messages/${m.id}/html${m.images_allowed ? "?images=1" : ""}`; const htmlUrl = `/api/messages/${m.id}/html${m.images_allowed ? "?images=1" : ""}`;
if (!(await isSaved(htmlUrl))) { if (!(await isSaved(htmlUrl))) {
const html = await viaWorker(htmlUrl); const html = await viaWorker(htmlUrl);
@@ -92,6 +135,10 @@ async function saveMessage(m: MessageEnvelope): Promise<void> {
async function run(): Promise<void> { async function run(): Promise<void> {
if (!(await offlineReady()) || isOffline()) return; if (!(await offlineReady()) || isOffline()) return;
for (const path of STARTUP) await viaWorker(`/api${path}`); for (const path of STARTUP) await viaWorker(`/api${path}`);
// Plugin scripts the mail view mounts (the Labels sidebar…).
const manifest = await getJson<UiManifest>("/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<UserSettings>("/settings"); const settings = await getJson<UserSettings>("/settings");
const order = settings.thread_order ?? "asc"; const order = settings.thread_order ?? "asc";
// The list as the app shows it (newest first is the default), plus the other // The list as the app shows it (newest first is the default), plus the other
@@ -102,6 +149,7 @@ async function run(): Promise<void> {
} }
await inPool(list.threads, PARALLEL, async (summary) => { await inPool(list.threads, PARALLEL, async (summary) => {
for (const who of summary.participants) await saveAvatar(who.email);
const detail = await getJson<ThreadDetail>(threadPath(summary.id, order, INBOX)); const detail = await getJson<ThreadDetail>(threadPath(summary.id, order, INBOX));
for (const m of detail.messages) await saveMessage(m); for (const m of detail.messages) await saveMessage(m);
}); });
@@ -117,3 +165,24 @@ export function prefetchRecent(): Promise<void> {
}); });
return running; 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<boolean> {
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;
}
}
+18
View File
@@ -5,6 +5,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { z } from "zod"; import { z } from "zod";
import { signedOut } from "./auth"; import { signedOut } from "./auth";
import { isOffline, onOnline } from "./offline";
const frameSchema = z.object({ const frameSchema = z.object({
event: z.string(), event: z.string(),
@@ -142,6 +143,12 @@ export function connectWs(queryClient: QueryClient): () => void {
socket.onclose = () => { socket.onclose = () => {
if (ping !== undefined) window.clearInterval(ping); if (ping !== undefined) window.clearInterval(ping);
if (stopped) return; 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. // Exponential backoff, capped at 30s, so a downed server is retried politely.
const delay = Math.min(30000, 1000 * 2 ** attempts); const delay = Math.min(30000, 1000 * 2 ** attempts);
attempts += 1; attempts += 1;
@@ -150,10 +157,21 @@ export function connectWs(queryClient: QueryClient): () => void {
socket.onerror = () => socket?.close(); // route errors through onclose -> reconnect 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(); connect();
return () => { return () => {
stopped = true; stopped = true;
stopWaiting?.();
if (ping !== undefined) window.clearInterval(ping); if (ping !== undefined) window.clearInterval(ping);
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer); if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
socket?.close(); socket?.close();
+4 -2
View File
@@ -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`; 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() { function OfflineAccessSetting() {
const t = useT(); const t = useT();
const enabled = useOfflineEnabled(); const enabled = useOfflineEnabled();
@@ -439,7 +439,6 @@ function SecuritySection() {
))} ))}
</ul> </ul>
</div> </div>
<OfflineAccessSetting />
</section> </section>
); );
} }
@@ -546,6 +545,9 @@ function AccountsSection() {
</li> </li>
))} ))}
</ul> </ul>
<div className="border-t border-app-border pt-5">
<OfflineAccessSetting />
</div>
{deletingId && ( {deletingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3 rounded-xl bg-app-elevated px-8 py-6 shadow-2xl"> <div className="flex flex-col items-center gap-3 rounded-xl bg-app-elevated px-8 py-6 shadow-2xl">