diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 0092c086e0..bbb4c5fa42 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -18,6 +18,7 @@ import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider"; import { Toaster } from "@/shared/ui/sonner"; import { TooltipProvider } from "@/shared/ui/tooltip"; import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota"; +import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep"; type E2eWindow = Window & { __BUZZ_E2E__?: unknown; @@ -122,6 +123,7 @@ async function bootstrap() { resetDevWebviewStateFromUrl(); configureDevE2eBridgeFromUrl(); recoverLocalStorageQuotaOnStartup(); + startLocalStorageSweep(); await installE2eBridgeIfConfigured(); await migrateLegacyCommunityStorageBeforeRender(); renderApp(); diff --git a/desktop/src/shared/lib/localStorageSweep.test.mjs b/desktop/src/shared/lib/localStorageSweep.test.mjs new file mode 100644 index 0000000000..668bf6d321 --- /dev/null +++ b/desktop/src/shared/lib/localStorageSweep.test.mjs @@ -0,0 +1,316 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LOCAL_STORAGE_SWEEP_RULES, + startLocalStorageSweep, + sweepStaleLocalStorage, +} from "./localStorageSweep.ts"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +function makeLocalStorage(entries = []) { + const store = new Map(entries); + return { + store, + get length() { + return store.size; + }, + key: (index) => [...store.keys()][index] ?? null, + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + }; +} + +function installWindow(localStorage, overrides = {}) { + globalThis.window = { localStorage, ...overrides }; +} + +const snapshot = (updatedAt) => JSON.stringify({ updatedAt, payload: "cache" }); + +test("sweeps stale whitelisted caches and keeps fresh or durable state", () => { + const now = 100 * DAY_MS; + const entries = LOCAL_STORAGE_SWEEP_RULES.flatMap( + ({ keyPrefix, maxAgeMs }, index) => [ + [`${keyPrefix}stale-a-${index}`, snapshot(now - maxAgeMs)], + [`${keyPrefix}stale-b-${index}`, snapshot(now - maxAgeMs - DAY_MS)], + [`${keyPrefix}fresh-${index}`, snapshot(now - maxAgeMs + 1)], + ], + ); + entries.push(["buzz-communities", snapshot(0)]); + entries.push(["buzz-theme", snapshot(0)]); + entries.push(["buzz-self-profile.v1:offline", snapshot(0)]); + const localStorage = makeLocalStorage(entries); + installWindow(localStorage); + + assert.equal( + sweepStaleLocalStorage(now), + LOCAL_STORAGE_SWEEP_RULES.length * 2, + ); + for (const { keyPrefix } of LOCAL_STORAGE_SWEEP_RULES) { + assert.equal( + [...localStorage.store.keys()].some((key) => + key.startsWith(`${keyPrefix}stale-`), + ), + false, + ); + assert.equal( + [...localStorage.store.keys()].some((key) => + key.startsWith(`${keyPrefix}fresh-`), + ), + true, + ); + } + assert.equal(localStorage.getItem("buzz-communities"), snapshot(0)); + assert.equal(localStorage.getItem("buzz-theme"), snapshot(0)); + assert.equal( + localStorage.getItem("buzz-self-profile.v1:offline"), + snapshot(0), + ); +}); + +test("uses the newest per-profile timestamp for user-label cache buckets", () => { + const now = 100 * DAY_MS; + const localStorage = makeLocalStorage([ + [ + "buzz-user-labels.v1:all-stale", + JSON.stringify({ + profiles: { + first: { updatedAt: now - 20 * DAY_MS }, + second: { updatedAt: now - 14 * DAY_MS }, + }, + }), + ], + [ + "buzz-user-labels.v1:one-fresh", + JSON.stringify({ + profiles: { + stale: { updatedAt: now - 20 * DAY_MS }, + fresh: { updatedAt: now - DAY_MS }, + }, + }), + ], + ]); + installWindow(localStorage); + + assert.equal(sweepStaleLocalStorage(now), 1); + assert.equal(localStorage.getItem("buzz-user-labels.v1:all-stale"), null); + assert.notEqual(localStorage.getItem("buzz-user-labels.v1:one-fresh"), null); +}); + +test("leaves malformed and timestamp-free cache entries untouched", () => { + const localStorage = makeLocalStorage([ + ["buzz-channel-messages.v1:malformed", "not json"], + ["buzz-channels.v1:no-timestamp", JSON.stringify({ payload: "cache" })], + ["buzz-observed-unread.v1:bad-timestamp", snapshot(Number.NaN)], + ]); + installWindow(localStorage); + + assert.equal(sweepStaleLocalStorage(100 * DAY_MS), 0); + assert.equal(localStorage.store.size, 3); +}); + +test("storage access failures warn and never escape", () => { + const originalWarn = console.warn; + const warnings = []; + console.warn = (...args) => warnings.push(args); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis.window, "localStorage", { + configurable: true, + get() { + throw new Error("SecurityError"); + }, + }); + + try { + assert.doesNotThrow(() => sweepStaleLocalStorage(100 * DAY_MS)); + assert.equal(sweepStaleLocalStorage(100 * DAY_MS), 0); + assert.equal(warnings.length, 2); + } finally { + console.warn = originalWarn; + } +}); + +test("scheduler setup failures warn and never escape startup", () => { + const originalDocument = globalThis.document; + const originalWarn = console.warn; + const warnings = []; + console.warn = (...args) => warnings.push(args); + globalThis.document = { + addEventListener() { + throw new Error("listener unavailable"); + }, + removeEventListener() {}, + visibilityState: "visible", + }; + installWindow(makeLocalStorage(), { + clearInterval() {}, + setInterval() { + throw new Error("timer unavailable"); + }, + }); + + try { + let stop; + assert.doesNotThrow(() => { + stop = startLocalStorageSweep(); + }); + assert.doesNotThrow(() => stop()); + assert.equal(warnings.length, 1); + } finally { + console.warn = originalWarn; + globalThis.document = originalDocument; + } +}); + +test("scheduler uses a deferred timer when requestIdleCallback is unavailable", () => { + const originalDocument = globalThis.document; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const documentTarget = new EventTarget(); + Object.defineProperty(documentTarget, "visibilityState", { + value: "visible", + }); + globalThis.document = documentTarget; + + const timeouts = new Map(); + let nextTimeoutId = 50; + globalThis.setTimeout = (callback, delay) => { + const id = nextTimeoutId++; + timeouts.set(id, { callback, delay }); + return id; + }; + globalThis.clearTimeout = (id) => timeouts.delete(id); + + const localStorage = makeLocalStorage([ + ["buzz-channel-messages.v1:startup", snapshot(0)], + ]); + installWindow(localStorage, { + setInterval: () => 1, + clearInterval() {}, + }); + + const stop = startLocalStorageSweep(); + try { + assert.notEqual( + localStorage.getItem("buzz-channel-messages.v1:startup"), + null, + ); + const [{ callback, delay }] = timeouts.values(); + assert.equal(delay, 250); + callback(); + assert.equal( + localStorage.getItem("buzz-channel-messages.v1:startup"), + null, + ); + } finally { + stop(); + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + globalThis.document = originalDocument; + } + assert.equal(timeouts.size, 0); +}); + +test("scheduler sweeps after idle, on debounced visibility, and hourly", () => { + const originalNow = Date.now; + const originalDocument = globalThis.document; + let now = 100 * DAY_MS; + Date.now = () => now; + + const documentTarget = new EventTarget(); + Object.defineProperty(documentTarget, "visibilityState", { + configurable: true, + value: "visible", + writable: true, + }); + globalThis.document = documentTarget; + + const intervals = new Map(); + const idleCallbacks = new Map(); + const cancelledIdleCallbacks = []; + let nextIntervalId = 1; + let nextIdleId = 100; + const localStorage = makeLocalStorage([ + ["buzz-channel-messages.v1:startup", snapshot(now - 14 * DAY_MS)], + ]); + installWindow(localStorage, { + requestIdleCallback(callback, options) { + const id = nextIdleId++; + idleCallbacks.set(id, { callback, options }); + return id; + }, + cancelIdleCallback(id) { + cancelledIdleCallbacks.push(id); + idleCallbacks.delete(id); + }, + setInterval(callback, delay) { + const id = nextIntervalId++; + intervals.set(id, { callback, delay }); + return id; + }, + clearInterval: (id) => intervals.delete(id), + }); + + let idleId; + const stop = startLocalStorageSweep(); + try { + assert.notEqual( + localStorage.getItem("buzz-channel-messages.v1:startup"), + null, + "initial sweep must not run synchronously on the boot path", + ); + assert.equal(idleCallbacks.size, 1); + const idleEntry = idleCallbacks.entries().next().value; + idleId = idleEntry[0]; + const { callback: idleCallback, options } = idleEntry[1]; + assert.equal(options.timeout, 1_500); + idleCallback(); + assert.equal( + localStorage.getItem("buzz-channel-messages.v1:startup"), + null, + ); + assert.equal(intervals.size, 1); + const [{ callback, delay }] = intervals.values(); + assert.equal(delay, 60 * 60 * 1_000); + + localStorage.setItem( + "buzz-channel-messages.v1:visibility", + snapshot(now - 14 * DAY_MS), + ); + now += 60 * 1_000; + documentTarget.dispatchEvent(new Event("visibilitychange")); + assert.notEqual( + localStorage.getItem("buzz-channel-messages.v1:visibility"), + null, + ); + + now += 5 * 60 * 1_000; + documentTarget.dispatchEvent(new Event("visibilitychange")); + assert.equal( + localStorage.getItem("buzz-channel-messages.v1:visibility"), + null, + ); + + localStorage.setItem( + "buzz-channel-messages.v1:interval", + snapshot(now - 14 * DAY_MS), + ); + now += 60 * 60 * 1_000; + callback(); + assert.equal( + localStorage.getItem("buzz-channel-messages.v1:interval"), + null, + ); + } finally { + stop(); + Date.now = originalNow; + globalThis.document = originalDocument; + } + assert.equal(intervals.size, 0); + assert.deepEqual(cancelledIdleCallbacks, [idleId]); +}); diff --git a/desktop/src/shared/lib/localStorageSweep.ts b/desktop/src/shared/lib/localStorageSweep.ts new file mode 100644 index 0000000000..d2c2c486b3 --- /dev/null +++ b/desktop/src/shared/lib/localStorageSweep.ts @@ -0,0 +1,157 @@ +/** + * Best-effort time-based cleanup for disposable localStorage caches. + * + * Only explicitly whitelisted cache namespaces are eligible. Durable state + * such as identities, communities, read positions, onboarding, and preferences + * must never be added here. + */ + +const DAY_MS = 24 * 60 * 60 * 1_000; +const SWEEP_INTERVAL_MS = 60 * 60 * 1_000; +const SWEEP_DEBOUNCE_MS = 5 * 60 * 1_000; +const INITIAL_SWEEP_FALLBACK_MS = 250; +const INITIAL_SWEEP_IDLE_TIMEOUT_MS = 1_500; + +type LocalStorageSweepRule = { + keyPrefix: string; + maxAgeMs: number; +}; + +/** Disposable cache namespaces and their maximum idle age. */ +export const LOCAL_STORAGE_SWEEP_RULES: readonly LocalStorageSweepRule[] = [ + { keyPrefix: "buzz-channel-messages.v1:", maxAgeMs: 14 * DAY_MS }, + { keyPrefix: "buzz-channels.v1:", maxAgeMs: 14 * DAY_MS }, + { keyPrefix: "buzz-observed-unread.v1:", maxAgeMs: 14 * DAY_MS }, + { keyPrefix: "buzz-sidebar-skeleton-shape.v1:", maxAgeMs: 14 * DAY_MS }, + { keyPrefix: "buzz-timeline-skeleton-shape.v1:", maxAgeMs: 14 * DAY_MS }, + { keyPrefix: "buzz-user-labels.v1:", maxAgeMs: 14 * DAY_MS }, + // Do not add buzz-self-profile.v1: here. It is the load-bearing offline + // identity fallback when the relay is unreachable, not a repaintable cache. +]; + +function updatedAtFromJson(value: string): number | null { + try { + const parsed = JSON.parse(value) as unknown; + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + if ( + typeof record.updatedAt === "number" && + Number.isFinite(record.updatedAt) + ) { + return record.updatedAt; + } + + // User-label cache buckets carry freshness per profile instead of at the + // payload root. Use the newest valid label timestamp so the bucket is only + // removed once every label in it is stale. + if (typeof record.profiles !== "object" || record.profiles === null) { + return null; + } + let newestUpdatedAt: number | null = null; + for (const profile of Object.values(record.profiles)) { + if (typeof profile !== "object" || profile === null) continue; + const updatedAt = (profile as Record).updatedAt; + if ( + typeof updatedAt === "number" && + Number.isFinite(updatedAt) && + (newestUpdatedAt === null || updatedAt > newestUpdatedAt) + ) { + newestUpdatedAt = updatedAt; + } + } + return newestUpdatedAt; + } catch { + return null; + } +} + +/** + * Removes whitelisted cache entries older than their configured TTL. + * Entries without a trustworthy `updatedAt` are left alone rather than guessed + * stale. Storage and parse failures never escape into app startup. + */ +export function sweepStaleLocalStorage(now = Date.now()): number { + let removed = 0; + try { + const storage = window.localStorage; + const staleKeys: string[] = []; + + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key === null) continue; + const rule = LOCAL_STORAGE_SWEEP_RULES.find(({ keyPrefix }) => + key.startsWith(keyPrefix), + ); + if (!rule) continue; + + const value = storage.getItem(key); + if (value === null) continue; + const updatedAt = updatedAtFromJson(value); + if (updatedAt !== null && updatedAt <= now - rule.maxAgeMs) { + staleKeys.push(key); + } + } + + // Collect before mutating because localStorage indexes shift on removal. + for (const key of staleKeys) { + storage.removeItem(key); + removed++; + } + } catch (error) { + console.warn("[localStorageSweep] stale cache cleanup failed:", error); + } + return removed; +} + +/** + * Defers the first sweep until the browser is idle (or a short timer fallback), + * then sweeps hourly while the app remains open and when a hidden app becomes + * visible. Visibility sweeps are debounced to avoid repeated work from rapid + * focus changes. Returns a cleanup function for tests or future teardown. + */ +export function startLocalStorageSweep(): () => void { + let lastSweepAt = Number.NEGATIVE_INFINITY; + let listening = false; + let intervalId: ReturnType | null = null; + let idleCallbackId: number | null = null; + let timeoutId: ReturnType | null = null; + const runIfDue = () => { + const now = Date.now(); + if (now - lastSweepAt < SWEEP_DEBOUNCE_MS) return; + lastSweepAt = now; + sweepStaleLocalStorage(now); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") runIfDue(); + }; + + try { + document.addEventListener("visibilitychange", onVisibilityChange); + listening = true; + intervalId = window.setInterval(runIfDue, SWEEP_INTERVAL_MS); + if ("requestIdleCallback" in window) { + idleCallbackId = window.requestIdleCallback(runIfDue, { + timeout: INITIAL_SWEEP_IDLE_TIMEOUT_MS, + }); + } else { + timeoutId = globalThis.setTimeout(runIfDue, INITIAL_SWEEP_FALLBACK_MS); + } + } catch (error) { + console.warn("[localStorageSweep] scheduler setup failed:", error); + } + + return () => { + try { + if (listening) { + document.removeEventListener("visibilitychange", onVisibilityChange); + } + if (intervalId !== null) window.clearInterval(intervalId); + if (idleCallbackId !== null && "cancelIdleCallback" in window) { + window.cancelIdleCallback(idleCallbackId); + } + if (timeoutId !== null) globalThis.clearTimeout(timeoutId); + } catch (error) { + console.warn("[localStorageSweep] scheduler cleanup failed:", error); + } + }; +}