// Blob cache for decrypted attachments. Stores the Blob body in OPFS // (Origin Private File System — always sandbox-scoped to the origin, no // user prompt) keyed by attachment id. Avoids re-downloading + re-decrypting // the same blob on every scroll-into-view. // // Tauri WebKit + WebView2 both support OPFS as of the versions targeted by // tauri 2. On unsupported hosts the cache degrades to a no-op and callers // fall back to the fetch path. const DIR_NAME = 'attachments'; const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000; interface OpfsRoot { getDirectoryHandle: ( name: string, opts?: { create?: boolean }, ) => Promise; } interface OpfsDir { getFileHandle: ( name: string, opts?: { create?: boolean }, ) => Promise; removeEntry: (name: string, opts?: { recursive?: boolean }) => Promise; entries?: () => AsyncIterableIterator<[string, OpfsFile]>; } interface OpfsFile { getFile: () => Promise; createWritable: () => Promise<{ write: (data: ArrayBuffer | Blob) => Promise; close: () => Promise; }>; } let dirPromise: Promise | null = null; async function getDir(): Promise { if (!dirPromise) { dirPromise = (async () => { const storage = (navigator as unknown as { storage?: { getDirectory?: () => Promise } }).storage; const getDirectory = storage?.getDirectory; if (!storage || !getDirectory) return null; try { const root = await getDirectory.call(storage); return await root.getDirectoryHandle(DIR_NAME, { create: true }); } catch (err: unknown) { console.warn('opfs attachment cache init failed', err); return null; } })(); } return dirPromise; } function safeName(id: string): string { // OPFS file names can't contain slashes; attachment ids are UUIDs so this // is mostly defensive. return id.replace(/[^A-Za-z0-9_.-]/g, '_') + '.bin'; } export async function getCachedAttachment(id: string): Promise { const dir = await getDir(); if (!dir) return null; try { const handle = await dir.getFileHandle(safeName(id), { create: false }); const file = await handle.getFile(); // Evict stale entries lazily — if older than TTL, drop and miss. if (Date.now() - file.lastModified > DEFAULT_TTL_MS) { await dir.removeEntry(safeName(id)).catch(() => undefined); return null; } return file; } catch { // File doesn't exist → cache miss. return null; } } export async function putCachedAttachment(id: string, blob: Blob): Promise { const dir = await getDir(); if (!dir) return; try { const handle = await dir.getFileHandle(safeName(id), { create: true }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); } catch (err: unknown) { console.warn('putCachedAttachment failed', err); } } export async function evictCachedAttachment(id: string): Promise { const dir = await getDir(); if (!dir) return; try { await dir.removeEntry(safeName(id)); } catch { /* ignore — probably never existed */ } }