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:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T>(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}`, {
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
|
||||
class Unreachable extends Error {}
|
||||
|
||||
/** Fetch through the service worker, which saves the answer; returns its text. */
|
||||
async function viaWorker(url: string): Promise<string | null> {
|
||||
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<T>(worker: ServiceWorker, message: object, ms: number): Promise<T> {
|
||||
return new Promise<T>((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<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) {
|
||||
reportOffline();
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
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<void> {
|
||||
async function run(): Promise<void> {
|
||||
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<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 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<void> {
|
||||
}
|
||||
|
||||
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));
|
||||
for (const m of detail.messages) await saveMessage(m);
|
||||
});
|
||||
@@ -117,3 +165,24 @@ export function prefetchRecent(): Promise<void> {
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<OfflineAccessSetting />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -546,6 +545,9 @@ function AccountsSection() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-app-border pt-5">
|
||||
<OfflineAccessSetting />
|
||||
</div>
|
||||
{deletingId && (
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user