mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
683 lines
27 KiB
TypeScript
683 lines
27 KiB
TypeScript
/**
|
|
* Offline mode (#523): "Keep mail on this device".
|
|
*
|
|
* - Opt-in per account and per browser (state.ts), off by default, hidden
|
|
* and wiped when the organisation forbids it (policy.json
|
|
* `organisation.devices.noOffline`, per account) and in Lite.
|
|
* - While on, the JMAP client hands every answer of the mail server to the
|
|
* cache (cache.ts) and asks this device when the network is gone
|
|
* (responder.ts); a background sync keeps the last 30 days of the Inbox
|
|
* and of the folders the user opened, with bodies and attachments, under
|
|
* a cap (sync.ts).
|
|
* - Changes and sends made offline wait in the queue (queue.ts) and go out
|
|
* in order once the server answers again (flush.ts).
|
|
* - Everything is wiped at a full sign-out and the automatic idle sign-out
|
|
* (lib/sign-out-cleanup.ts deletes the database), for one account when it
|
|
* signs out or switches offline mode off, and when the organisation
|
|
* forbids it (checked on every policy fetch).
|
|
*/
|
|
|
|
import { IS_LITE } from '@/lib/lite';
|
|
import { devicesForAccount } from '@/lib/device-policy';
|
|
import { useAccountStore } from '@/stores/account-store';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store';
|
|
import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
|
|
import { setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks';
|
|
import { setOutboxHooks, type OutboxHooks } from './outbox-hooks';
|
|
import { answerOffline } from './responder';
|
|
import {
|
|
CACHE_WINDOW_MS,
|
|
cacheUsage,
|
|
emailIndex,
|
|
forgetIndexes,
|
|
getBlob,
|
|
getMisc,
|
|
patchEmails,
|
|
putBlob,
|
|
putEmails,
|
|
putMisc,
|
|
removeEmails,
|
|
wipeAccountCache,
|
|
emailKey,
|
|
type CachedEmail,
|
|
} from './cache';
|
|
import { deleteOfflineDb, hasIndexedDb } from './db';
|
|
import { forgetDeviceKey } from './crypto';
|
|
import { heldFile, holdFile, isLocalBlobId, queuePatch, queueSend, readQueue, wipeAccountQueue, type OutboxSummary } from './queue';
|
|
import { isOfflineNow, useOfflineStore, writeEnabled, type OutboxRow } from './state';
|
|
import { flushQueue } from './flush';
|
|
import { syncAccount } from './sync';
|
|
import { deleteShellCaches, tellServiceWorker } from './shell';
|
|
|
|
export type OfflineNotice =
|
|
| { kind: 'queued'; subject: string }
|
|
| { kind: 'sent'; subject: string }
|
|
| { kind: 'send-failed'; subject: string; error: string }
|
|
| { kind: 'refused-delete' }
|
|
| { kind: 'refused-other' }
|
|
| { kind: 'forbidden' }
|
|
| { kind: 'back-online-reload' };
|
|
|
|
let notify: (notice: OfflineNotice) => void = () => {};
|
|
|
|
/** Where the notices go (the host component turns them into toasts). */
|
|
export function setOfflineNotifier(fn: (notice: OfflineNotice) => void): void {
|
|
notify = fn;
|
|
}
|
|
|
|
export function offlineNotify(notice: OfflineNotice): void {
|
|
notify(notice);
|
|
}
|
|
|
|
// ---- Who may use it ----
|
|
|
|
/** Offline mode is possible here at all: not in Lite, with IndexedDB and WebCrypto. */
|
|
export function offlineSupported(): boolean {
|
|
if (IS_LITE || !hasIndexedDb()) return false;
|
|
try {
|
|
return typeof crypto !== 'undefined' && !!crypto.subtle;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** The organisation allows this account to keep mail on the device. */
|
|
export function offlineAllowed(account: string | null | undefined): boolean {
|
|
return offlineSupported() && !devicesForAccount(account ?? null).noOffline;
|
|
}
|
|
|
|
/** "Keep mail on this device" is on for this account here, and allowed. */
|
|
export function offlineActive(account: string | null | undefined): boolean {
|
|
return !!account && useOfflineStore.getState().enabled.includes(account) && offlineAllowed(account);
|
|
}
|
|
|
|
/** The local account a JMAP client belongs to. */
|
|
export function accountForClient(client: OfflineClientRef): string | null {
|
|
const auth = useAuthStore.getState();
|
|
if (client === (auth.client as unknown) && auth.activeAccountId) return auth.activeAccountId;
|
|
try {
|
|
for (const [id, c] of auth.getAllConnectedClients()) if ((c as unknown) === client) return id;
|
|
} catch {
|
|
// A stand-in auth store in tests.
|
|
}
|
|
const server = client.getServerUrl().replace(/\/+$/, '');
|
|
const user = client.getUsername();
|
|
const entry = useAccountStore.getState().accounts.find((a) => a.serverUrl.replace(/\/+$/, '') === server && a.username === user);
|
|
return entry?.id ?? null;
|
|
}
|
|
|
|
function onlineClientFor(account: string) {
|
|
const client = useAuthStore.getState().getClientForAccount(account);
|
|
if (!client || client.isOfflineOnly()) return null;
|
|
return client;
|
|
}
|
|
|
|
// ---- What the server sends, into the cache ----
|
|
|
|
interface Scope {
|
|
inbox: Record<string, string>;
|
|
opened: Record<string, string[]>;
|
|
}
|
|
|
|
const MAX_OPENED_FOLDERS = 10;
|
|
|
|
async function scopeOf(account: string): Promise<Scope> {
|
|
const mailboxes = (await getMisc<Record<string, Array<{ id: string; role?: string | null }>>>(account, 'mailboxes')) ?? {};
|
|
const inbox: Record<string, string> = {};
|
|
for (const [jmap, list] of Object.entries(mailboxes)) {
|
|
const box = list.find((m) => m.role === 'inbox');
|
|
if (box) inbox[jmap] = box.id;
|
|
}
|
|
const opened = (await getMisc<Record<string, string[]>>(account, 'opened')) ?? {};
|
|
return { inbox, opened };
|
|
}
|
|
|
|
export async function scopeMailboxes(account: string): Promise<Array<{ jmap: string; mailboxId: string; isInbox: boolean }>> {
|
|
const scope = await scopeOf(account);
|
|
const out: Array<{ jmap: string; mailboxId: string; isInbox: boolean }> = [];
|
|
for (const [jmap, id] of Object.entries(scope.inbox)) out.push({ jmap, mailboxId: id, isInbox: true });
|
|
for (const [jmap, ids] of Object.entries(scope.opened)) {
|
|
for (const id of ids) if (scope.inbox[jmap] !== id) out.push({ jmap, mailboxId: id, isInbox: false });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function inScope(scope: Scope, jmap: string, email: CachedEmail, now = Date.now()): boolean {
|
|
const at = Date.parse(String(email.receivedAt ?? ''));
|
|
if (Number.isFinite(at) && at < now - CACHE_WINDOW_MS) return false;
|
|
const boxes = Object.keys(email.mailboxIds ?? {});
|
|
const wanted = new Set([...(scope.opened[jmap] ?? []), ...(scope.inbox[jmap] ? [scope.inbox[jmap]] : [])]);
|
|
return boxes.some((id) => wanted.has(id));
|
|
}
|
|
|
|
/** The folder a list query opened, if it is one. */
|
|
function openedFolder(args: Record<string, unknown>): string | null {
|
|
if (typeof args.limit !== 'number') return null;
|
|
const filter = args.filter as Record<string, unknown> | undefined;
|
|
if (!filter) return null;
|
|
if (typeof filter.inMailbox === 'string' && Object.keys(filter).every((k) => k === 'inMailbox' || k === 'hasKeyword')) return filter.inMailbox;
|
|
if (filter.operator === 'AND' && Array.isArray(filter.conditions)) {
|
|
for (const c of filter.conditions as Array<Record<string, unknown>>) if (typeof c.inMailbox === 'string' && Object.keys(c).length === 1) return c.inMailbox;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function noteOpenedFolder(account: string, jmap: string, mailboxId: string): Promise<void> {
|
|
const opened = (await getMisc<Record<string, string[]>>(account, 'opened')) ?? {};
|
|
const list = opened[jmap] ?? [];
|
|
if (list[0] === mailboxId) return;
|
|
opened[jmap] = [mailboxId, ...list.filter((id) => id !== mailboxId)].slice(0, MAX_OPENED_FOLDERS);
|
|
await putMisc(account, 'opened', opened);
|
|
scheduleSync(account, 5_000);
|
|
}
|
|
|
|
async function putList(account: string, name: 'mailboxes' | 'identities', jmap: string, list: unknown[]): Promise<void> {
|
|
const all = (await getMisc<Record<string, unknown[]>>(account, name)) ?? {};
|
|
all[jmap] = list;
|
|
await putMisc(account, name, all);
|
|
}
|
|
|
|
async function recordAnswer(account: string, defaultJmap: string, calls: OfflineMethodCall[], response: OfflineResponse): Promise<void> {
|
|
const scope = await scopeOf(account);
|
|
for (const [name, result, callId] of response.methodResponses) {
|
|
if (!result || typeof result !== 'object') continue;
|
|
const call = calls.find(([n, , id]) => id === callId && n === name);
|
|
const args = call?.[1] ?? {};
|
|
const jmap = (result.accountId as string) || (args.accountId as string) || defaultJmap;
|
|
const properties = args.properties as string[] | undefined;
|
|
switch (name) {
|
|
case 'Mailbox/get':
|
|
if (!args.ids && (!properties || properties.includes('name')) && Array.isArray(result.list)) {
|
|
await putList(account, 'mailboxes', jmap, result.list);
|
|
}
|
|
break;
|
|
case 'Identity/get':
|
|
if (!args.ids && Array.isArray(result.list)) await putList(account, 'identities', jmap, result.list);
|
|
break;
|
|
case 'Email/get': {
|
|
const list = (Array.isArray(result.list) ? result.list : []) as CachedEmail[];
|
|
// Fresh state for every message already kept; new ones only within the scope.
|
|
const index = await emailIndex(account);
|
|
const keep = list.filter((email) => index.has(emailKey(account, jmap, email.id)) || inScope(scope, jmap, email));
|
|
await putEmails(account, jmap, keep);
|
|
break;
|
|
}
|
|
case 'Email/query': {
|
|
const folder = openedFolder(args);
|
|
if (folder) await noteOpenedFolder(account, jmap, folder);
|
|
break;
|
|
}
|
|
case 'Email/set': {
|
|
const update = (args.update ?? {}) as Record<string, Record<string, unknown>>;
|
|
const updated = Object.keys((result.updated ?? {}) as object);
|
|
if (updated.length) await patchEmails(account, jmap, Object.fromEntries(updated.filter((id) => update[id]).map((id) => [id, update[id]])));
|
|
const destroyed = (Array.isArray(result.destroyed) ? result.destroyed : []) as string[];
|
|
if (destroyed.length) await removeEmails(account, destroyed.map((id) => emailKey(account, jmap, id)));
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// One write after the other per account, so a list and its refresh land in order.
|
|
const recording = new Map<string, Promise<void>>();
|
|
|
|
function enqueueRecord(account: string, task: () => Promise<void>): void {
|
|
const previous = recording.get(account) ?? Promise.resolve();
|
|
const next = previous.then(task).catch(() => {});
|
|
recording.set(account, next);
|
|
}
|
|
|
|
// ---- Hooks for the JMAP client ----
|
|
|
|
let networkWasDown = false;
|
|
let probing: Promise<boolean> | null = null;
|
|
|
|
/**
|
|
* A request failed on the network. Browsers report a refusal without CORS
|
|
* headers (Stalwart answering "too many requests at once") the same way as
|
|
* no network at all, so the server is asked once more without CORS: only
|
|
* when that fails too is the server counted as gone, and sends go to the
|
|
* outbox.
|
|
*/
|
|
function probeServer(client: OfflineClientRef): Promise<boolean> {
|
|
if (probing) return probing;
|
|
probing = (async () => {
|
|
try {
|
|
await fetch(`${client.getServerUrl().replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' });
|
|
return false;
|
|
} catch {
|
|
networkWasDown = true;
|
|
if (!useOfflineStore.getState().networkDown) useOfflineStore.setState({ networkDown: true });
|
|
return true;
|
|
}
|
|
})().finally(() => { probing = null; });
|
|
return probing;
|
|
}
|
|
|
|
const hooks: OfflineHooks = {
|
|
skipNetwork(client) {
|
|
if (typeof navigator === 'undefined' || navigator.onLine !== false) return false;
|
|
return offlineActive(accountForClient(client));
|
|
},
|
|
async answer(client, methodCalls) {
|
|
const account = accountForClient(client);
|
|
if (!offlineActive(account) || !account) return null;
|
|
return answerOffline({
|
|
account,
|
|
defaultJmap: client.getAccountId(),
|
|
queuePatch: async (jmap, update) => {
|
|
await queuePatch(account, jmap, update);
|
|
void refreshQueueState();
|
|
},
|
|
onRefused: (what) => notify({ kind: what === 'destroy' ? 'refused-delete' : 'refused-other' }),
|
|
}, methodCalls);
|
|
},
|
|
record(client, methodCalls, response) {
|
|
const account = accountForClient(client);
|
|
if (!offlineActive(account) || !account) return;
|
|
// Callers change the answer's objects after this returns.
|
|
const copy = JSON.parse(JSON.stringify(response)) as OfflineResponse;
|
|
const calls = JSON.parse(JSON.stringify(methodCalls)) as OfflineMethodCall[];
|
|
const jmap = client.getAccountId();
|
|
enqueueRecord(account, () => recordAnswer(account, jmap, calls, copy));
|
|
},
|
|
noteNetwork(client, reachable) {
|
|
if (reachable) {
|
|
if (useOfflineStore.getState().networkDown) useOfflineStore.setState({ networkDown: false });
|
|
if (networkWasDown) {
|
|
networkWasDown = false;
|
|
scheduleFlushAll(500);
|
|
}
|
|
} else {
|
|
void probeServer(client);
|
|
}
|
|
},
|
|
async serverGone(client) {
|
|
if (!offlineActive(accountForClient(client))) return false;
|
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) return true;
|
|
return probeServer(client);
|
|
},
|
|
async blob(client, blobId, jmap) {
|
|
const account = accountForClient(client);
|
|
if (!account || !offlineActive(account)) return null;
|
|
if (isLocalBlobId(blobId)) return heldFile(account, blobId);
|
|
return getBlob(account, jmap, blobId);
|
|
},
|
|
recordBlob(client, blobId, jmap, blob) {
|
|
const account = accountForClient(client);
|
|
if (!account || !offlineActive(account) || blob.size > MAX_RECORDED_BLOB_BYTES) return;
|
|
enqueueRecord(account, () => putBlob(account, jmap, blobId, blob, Date.now()));
|
|
},
|
|
async holdUpload(client, file) {
|
|
const account = accountForClient(client);
|
|
if (!account || !offlineActive(account)) return null;
|
|
return holdFile(account, file, file.name);
|
|
},
|
|
};
|
|
|
|
/** Attachments and images opened while online are kept up to this size. */
|
|
const MAX_RECORDED_BLOB_BYTES = 10 * 1024 * 1024;
|
|
|
|
// ---- The outbox ----
|
|
|
|
function summaryOf(args: unknown[]): OutboxSummary {
|
|
const [to, subject, , cc, bcc] = args as [string[] | undefined, string | undefined, unknown, string[] | undefined, string[] | undefined];
|
|
return { subject: subject ?? '', to: [...(to ?? []), ...(cc ?? []), ...(bcc ?? [])] };
|
|
}
|
|
|
|
function queuedResult(): { scheduled: false; queued: true } {
|
|
return { scheduled: false, queued: true };
|
|
}
|
|
|
|
const outbox: OutboxHooks = {
|
|
shouldQueue(client) {
|
|
const account = accountForClient(client);
|
|
return !!account && offlineActive(account) && (isOfflineNow() || (typeof navigator !== 'undefined' && navigator.onLine === false));
|
|
},
|
|
async queueEmail(client, args) {
|
|
const account = accountForClient(client);
|
|
if (!account) throw new Error('No account for this send');
|
|
const options = { ...((args[15] as Record<string, unknown> | undefined) ?? {}) };
|
|
delete options.outbox;
|
|
const stored = [...args.slice(0, 15), options];
|
|
const summary = summaryOf(stored);
|
|
await queueSend(account, summary, { kind: 'email', args: stored });
|
|
await refreshQueueState();
|
|
notify({ kind: 'queued', subject: summary.subject });
|
|
return queuedResult();
|
|
},
|
|
async queueRaw(client, raw, options) {
|
|
const account = accountForClient(client);
|
|
if (!account) throw new Error('No account for this send');
|
|
const held = await holdFile(account, raw, 'message.eml');
|
|
const summary = await rawSummary(raw);
|
|
await queueSend(account, summary, { kind: 'raw', blobId: held.blobId, ...options });
|
|
await refreshQueueState();
|
|
notify({ kind: 'queued', subject: summary.subject });
|
|
return queuedResult();
|
|
},
|
|
async resolveHeldAttachments(client, attachments) {
|
|
const account = accountForClient(client);
|
|
if (!account || !attachments.some((a) => isLocalBlobId(a.blobId))) return attachments;
|
|
const real = onlineClientFor(account);
|
|
if (!real) return attachments;
|
|
const out = [];
|
|
for (const attachment of attachments) {
|
|
if (!isLocalBlobId(attachment.blobId)) { out.push(attachment); continue; }
|
|
const file = await heldFile(account, attachment.blobId);
|
|
if (!file) throw new Error('A file attached offline is no longer on this device. Attach it again.');
|
|
const { blobId } = await real.uploadBlob(file);
|
|
out.push({ ...attachment, blobId });
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
|
|
/** Subject and recipients of a raw message, for the outbox list. */
|
|
async function rawSummary(raw: Blob): Promise<OutboxSummary> {
|
|
const head = await raw.slice(0, 64 * 1024).text();
|
|
const end = head.search(/\r?\n\r?\n/);
|
|
const block = (end >= 0 ? head.slice(0, end) : head).replace(/\r?\n[ \t]+/g, ' ');
|
|
const header = (name: string) => block.split(/\r?\n/).find((line) => line.toLowerCase().startsWith(`${name}:`))?.slice(name.length + 1).trim() ?? '';
|
|
const to = ['to', 'cc'].flatMap((name) => header(name).split(',')).map((s) => s.trim()).filter(Boolean);
|
|
return { subject: decodeMimeWords(header('subject')), to };
|
|
}
|
|
|
|
function decodeMimeWords(value: string): string {
|
|
return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (whole, charset: string, encoding: string, text: string) => {
|
|
try {
|
|
const bytes = encoding.toUpperCase() === 'B'
|
|
? Uint8Array.from(atob(text), (c) => c.charCodeAt(0))
|
|
: Uint8Array.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, h: string) => String.fromCharCode(parseInt(h, 16))), (c) => c.charCodeAt(0));
|
|
return new TextDecoder(charset).decode(bytes);
|
|
} catch {
|
|
return whole;
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Reads the queue into the store, for the outbox view and the banner. */
|
|
export async function refreshQueueState(): Promise<void> {
|
|
if (!offlineSupported()) return;
|
|
try {
|
|
const { patches, outbox: entries } = await readQueue();
|
|
const rows: OutboxRow[] = entries.map((entry) => ({
|
|
id: entry.id,
|
|
account: entry.account,
|
|
createdAt: entry.createdAt,
|
|
subject: entry.summary.subject,
|
|
to: entry.summary.to,
|
|
status: entry.status,
|
|
error: entry.error,
|
|
}));
|
|
useOfflineStore.setState({ outbox: rows, pendingChanges: patches.length });
|
|
} catch {
|
|
// The database is being deleted.
|
|
}
|
|
}
|
|
|
|
export async function refreshUsage(): Promise<void> {
|
|
if (!offlineSupported()) return;
|
|
try {
|
|
useOfflineStore.setState({ usageBytes: (await cacheUsage()).total });
|
|
} catch {
|
|
/* deleted meanwhile */
|
|
}
|
|
}
|
|
|
|
// ---- Sync and flush timing ----
|
|
|
|
const syncTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
|
export function scheduleSync(account: string, delayMs = 2_000): void {
|
|
if (!offlineActive(account)) return;
|
|
const existing = syncTimers.get(account);
|
|
if (existing) clearTimeout(existing);
|
|
syncTimers.set(account, setTimeout(() => {
|
|
syncTimers.delete(account);
|
|
void runSync(account);
|
|
}, delayMs));
|
|
}
|
|
|
|
const syncsRunning = new Set<string>();
|
|
|
|
export async function runSync(account: string): Promise<void> {
|
|
if (syncsRunning.has(account) || !offlineActive(account)) return;
|
|
const client = onlineClientFor(account);
|
|
if (!client || isOfflineNow()) return;
|
|
syncsRunning.add(account);
|
|
useOfflineStore.setState((s) => ({ syncing: [...new Set([...s.syncing, account])] }));
|
|
try {
|
|
await syncAccount(account, client, {
|
|
stillActive: () => offlineActive(account),
|
|
recorded: async () => { await recording.get(account); },
|
|
scopeMailboxes,
|
|
});
|
|
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [account]: Date.now() } }));
|
|
} catch {
|
|
// Tried again at the next change or in 15 minutes.
|
|
} finally {
|
|
syncsRunning.delete(account);
|
|
useOfflineStore.setState((s) => ({ syncing: s.syncing.filter((id) => id !== account) }));
|
|
void refreshUsage();
|
|
}
|
|
}
|
|
|
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
export function scheduleFlushAll(delayMs = 1_000): void {
|
|
if (flushTimer) clearTimeout(flushTimer);
|
|
flushTimer = setTimeout(() => {
|
|
flushTimer = null;
|
|
void flushAll();
|
|
}, delayMs);
|
|
}
|
|
|
|
export async function flushAll(): Promise<void> {
|
|
if (useOfflineStore.getState().startedOffline) {
|
|
// Started without the server: the clients have no credentials. A reload signs in and sends.
|
|
if (typeof navigator === 'undefined' || navigator.onLine !== false) notify({ kind: 'back-online-reload' });
|
|
return;
|
|
}
|
|
const { outbox: rows, pendingChanges } = useOfflineStore.getState();
|
|
const accounts = new Set(rows.map((r) => r.account));
|
|
if (pendingChanges > 0) for (const id of useOfflineStore.getState().enabled) accounts.add(id);
|
|
for (const account of accounts) {
|
|
const client = onlineClientFor(account);
|
|
if (!client) continue;
|
|
await flushQueue(account, client, {
|
|
notify,
|
|
allowed: () => offlineAllowed(account),
|
|
});
|
|
}
|
|
await refreshQueueState();
|
|
for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 3_000);
|
|
}
|
|
|
|
// ---- Switching it on and off, wiping ----
|
|
|
|
export async function enableOffline(account: string): Promise<void> {
|
|
if (!offlineAllowed(account)) return;
|
|
const enabled = useOfflineStore.getState().enabled;
|
|
if (!enabled.includes(account)) writeEnabled([...enabled, account]);
|
|
// Ask the browser not to clear it when space runs low (it may say no).
|
|
try {
|
|
void navigator.storage?.persist?.().catch(() => false);
|
|
} catch {
|
|
/* no Storage API */
|
|
}
|
|
const client = onlineClientFor(account);
|
|
if (client) await putMisc(account, 'session', client.getSessionSnapshot());
|
|
await tellServiceWorker(true);
|
|
scheduleSync(account, 0);
|
|
}
|
|
|
|
/** Switches offline mode off for an account and removes everything it kept here. */
|
|
export async function disableOffline(account: string): Promise<void> {
|
|
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
|
await wipeAccount(account);
|
|
}
|
|
|
|
export async function wipeAccount(account: string): Promise<void> {
|
|
const timer = syncTimers.get(account);
|
|
if (timer) clearTimeout(timer);
|
|
syncTimers.delete(account);
|
|
if (useOfflineStore.getState().enabled.includes(account)) {
|
|
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
|
}
|
|
try {
|
|
await recording.get(account);
|
|
await wipeAccountCache(account);
|
|
await wipeAccountQueue(account);
|
|
} catch {
|
|
// No database: nothing to remove.
|
|
}
|
|
if (useOfflineStore.getState().enabled.length === 0) {
|
|
await wipeEverything();
|
|
return;
|
|
}
|
|
await refreshQueueState();
|
|
await refreshUsage();
|
|
}
|
|
|
|
/** Nothing of offline mode stays on this device. */
|
|
export async function wipeEverything(): Promise<void> {
|
|
for (const timer of syncTimers.values()) clearTimeout(timer);
|
|
syncTimers.clear();
|
|
writeEnabled([]);
|
|
forgetIndexes();
|
|
forgetDeviceKey();
|
|
await deleteOfflineDb();
|
|
await tellServiceWorker(false);
|
|
await deleteShellCaches();
|
|
useOfflineStore.setState({ outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {} });
|
|
}
|
|
|
|
/** The organisation forbids it for an account that has it on: wipe that account. */
|
|
export function enforcePolicy(): void {
|
|
const { loaded } = usePolicyStore.getState();
|
|
if (!loaded) return;
|
|
for (const account of useOfflineStore.getState().enabled) {
|
|
if (!offlineAllowed(account)) {
|
|
void wipeAccount(account);
|
|
notify({ kind: 'forbidden' });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Starting ----
|
|
|
|
let started = false;
|
|
const cleanups: Array<() => void> = [];
|
|
|
|
/** Runs offline mode in this tab. Safe to call more than once. */
|
|
export function startOfflineMode(): () => void {
|
|
if (started || !offlineSupported()) return () => {};
|
|
started = true;
|
|
setOfflineHooks(hooks);
|
|
setOutboxHooks(outbox);
|
|
|
|
const onOnline = () => {
|
|
useOfflineStore.setState({ browserOffline: false });
|
|
scheduleFlushAll(500);
|
|
};
|
|
const onOffline = () => useOfflineStore.setState({ browserOffline: true });
|
|
window.addEventListener('online', onOnline);
|
|
window.addEventListener('offline', onOffline);
|
|
cleanups.push(() => {
|
|
window.removeEventListener('online', onOnline);
|
|
window.removeEventListener('offline', onOffline);
|
|
});
|
|
|
|
// The organisation's word, on every policy fetch (and the cached copy at an offline start).
|
|
cleanups.push(usePolicyStore.subscribe((state, prev) => {
|
|
if (state.rawPolicy !== prev.rawPolicy || state.loaded !== prev.loaded || state.subject !== prev.subject) enforcePolicy();
|
|
}));
|
|
enforcePolicy();
|
|
|
|
// An account that signs out (or is removed) takes its offline data with it.
|
|
let known = new Set(useAccountStore.getState().accounts.map((a) => a.id));
|
|
cleanups.push(useAccountStore.subscribe((state) => {
|
|
const now = new Set(state.accounts.map((a) => a.id));
|
|
for (const id of known) if (!now.has(id)) void wipeAccount(id);
|
|
known = now;
|
|
}));
|
|
|
|
// Session, then a sync, for every account signed in with offline mode on.
|
|
const syncSignedIn = () => {
|
|
for (const account of useOfflineStore.getState().enabled) {
|
|
const client = onlineClientFor(account);
|
|
if (!client || !offlineActive(account)) continue;
|
|
void putMisc(account, 'session', client.getSessionSnapshot()).catch(() => {});
|
|
scheduleSync(account, 3_000);
|
|
}
|
|
};
|
|
cleanups.push(useAuthStore.subscribe((state, prev) => {
|
|
if (state.client !== prev.client || state.connectedAccountsRevision !== prev.connectedAccountsRevision) syncSignedIn();
|
|
}));
|
|
syncSignedIn();
|
|
|
|
const interval = setInterval(() => {
|
|
for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 0);
|
|
}, SYNC_INTERVAL_MS);
|
|
cleanups.push(() => clearInterval(interval));
|
|
|
|
if (useOfflineStore.getState().enabled.length > 0) void tellServiceWorker(true);
|
|
void refreshQueueState().then(() => {
|
|
if (!isOfflineNow()) scheduleFlushAll(2_000);
|
|
});
|
|
void refreshUsage();
|
|
|
|
return () => {
|
|
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
setOfflineHooks(null);
|
|
setOutboxHooks(null);
|
|
started = false;
|
|
};
|
|
}
|
|
|
|
/** How often the cache catches up while the tab is open. */
|
|
export const SYNC_INTERVAL_MS = 15 * 60 * 1000;
|
|
|
|
// A full sign-out deletes the database (lib/sign-out-cleanup.ts); forget
|
|
// what is held in memory as well.
|
|
onFullSignOut(() => {
|
|
for (const timer of syncTimers.values()) clearTimeout(timer);
|
|
syncTimers.clear();
|
|
forgetIndexes();
|
|
forgetDeviceKey();
|
|
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false });
|
|
});
|
|
|
|
// Unsent messages of the accounts signing out, named on the login page
|
|
// after an automatic sign-out (lib/session-policy/sign-out-reason.ts).
|
|
onBeforeSignOut(async (accountId) => {
|
|
const rows = useOfflineStore.getState().outbox.filter((row) => accountId === 'all' || row.account === accountId);
|
|
if (rows.length === 0) return;
|
|
const { noteOutboxLost } = await import('@/lib/session-policy/sign-out-reason');
|
|
noteOutboxLost(rows.map((row) => row.subject));
|
|
});
|
|
|
|
/** The session kept for an account, for starting without the server. */
|
|
export async function offlineSessionFor(account: string): Promise<unknown | null> {
|
|
// The organisation's last word: the policy kept from the last fetch (the
|
|
// fetch itself fails without a network), never the defaults.
|
|
const policy = usePolicyStore.getState();
|
|
if (!policy.loaded && !policy.fetching) await policy.fetchPolicy();
|
|
else await whenPolicySettled(3000);
|
|
if (!offlineActive(account)) return null;
|
|
try {
|
|
return await getMisc<unknown>(account, 'session');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function markStartedOffline(): void {
|
|
useOfflineStore.setState({ startedOffline: true });
|
|
}
|