diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts index edfad4809..1338162d0 100644 --- a/lib/desktop/__tests__/os-mail.test.ts +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -311,7 +311,8 @@ describe('from arrival to toast and sound', () => { }); it("the store's created event reaches a running watch, and nothing else", async () => { - const { noteStoreCreated, startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + const { reportStoreCreatedMail: noteStoreCreated } = await import('../system-events'); const notify = vi.fn(async () => ({ shown: true, playSound: false })); setBridge({ version: 1, platform: 'win32', os: { notify } as never }); client = fakeClient({ a: email('a') }); diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts new file mode 100644 index 000000000..7339a0aa4 --- /dev/null +++ b/lib/desktop/__tests__/os-store.test.ts @@ -0,0 +1,76 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StorePageHooks } from '@/lib/store/client/api'; + +/** The local store and the OS integration: mail it found, and a machine going to sleep. */ + +const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null })); +const setOnline = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('@/lib/store/client/api', async (original) => ({ + ...(await original()), + configureStore: (hooks: StorePageHooks | null) => { + if (hooks) configured.hooks = hooks; + }, + getStore: () => ({ + supported: () => true, + setOnline, + ensureOpen: async () => {}, + refresh: () => {}, + sync: async () => {}, + stop: () => {}, + }), +})); + +describe('the store and the OS integration', () => { + const client = { isOfflineOnly: () => false, getAccountId: () => 'acc' }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { useAuthStore } = await import('@/stores/auth-store'); + useAuthStore.setState({ getClientForAccount: () => client as never } as never); + const { registerOfflineHooks } = await import('@/lib/offline/offline-mode'); + registerOfflineHooks(); + }); + + afterEach(async () => { + const { setStoreCreatedListener } = await import('../system-events'); + setStoreCreatedListener(null); + }); + + it("hands the store's new mail to the notification path, with the login's client", async () => { + const { setStoreCreatedListener } = await import('../system-events'); + const listener = vi.fn(); + setStoreCreatedListener(listener); + configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1', 'e2'] }); + expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']); + }); + + it('other collections are not mail', async () => { + const { setStoreCreatedListener } = await import('../system-events'); + const listener = vi.fn(); + setStoreCreatedListener(listener); + configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'CalendarEvent', ids: ['c1'] }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('with nothing listening, as in a browser, it does nothing', () => { + expect(() => configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1'] })).not.toThrow(); + }); + + it('a sleep takes the engine offline; waking brings it back and syncs through the events it already listens to', async () => { + const { startOfflineMode, offlineSupported } = await import('@/lib/offline/offline-mode'); + expect(offlineSupported()).toBe(true); + const { runSleepHandlers } = await import('../system-events'); + const stop = startOfflineMode(); + runSleepHandlers(); + expect(setOnline).toHaveBeenLastCalledWith(false); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { onPower: (listener: (event: string) => void) => { listener('resume'); return () => {}; } } } as never; + const { startPowerWatch } = await import('../power'); + const stopPower = startPowerWatch(); + expect(setOnline).toHaveBeenLastCalledWith(true); + stopPower(); + stop(); + window.bulwarkDesktop = undefined; + }); +}); diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index f7f3a5012..2e005476f 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -271,7 +271,8 @@ describe('waking up', () => { let fire: (event: DesktopOsPowerEvent) => void = () => {}; setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os }); const power = await import('../power'); - return { fire: (event: DesktopOsPowerEvent) => fire(event), power, stop: power.startPowerWatch() }; + const events = await import('../system-events'); + return { fire: (event: DesktopOsPowerEvent) => fire(event), power: events, stop: power.startPowerWatch() }; } it('a resume checks for changes at once, the way a tab coming back does, and only once with the unlock', async () => { diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts index 090c9ebdf..05b3303ba 100644 --- a/lib/desktop/mail-notifications.ts +++ b/lib/desktop/mail-notifications.ts @@ -14,6 +14,7 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { DesktopOsNotification } from '@/lib/desktop/types'; import { isAppLocked } from '@/lib/desktop/app-lock-state'; import { startNewMailWatch, type NewMail, type NewMailWatch } from '@/lib/desktop/new-mail'; +import { setStoreCreatedListener } from '@/lib/desktop/system-events'; import { devicesForAccount } from '@/lib/device-policy'; import { playNotificationSound } from '@/lib/notification-sound'; import { isPopoutWindow } from '@/lib/popout/window-role'; @@ -130,17 +131,6 @@ export interface MailNotifications extends NewMailWatch { setLabels(labels: MailNotificationLabels): void; } -let current: MailNotifications | null = null; - -/** - * The local store's `created` event, from lib/offline/offline-mode.ts's onEvent, once - * an account lives in the store: `noteStoreCreated(client, event.jmap, event.ids)`. - * Does nothing where notifications don't run: in a browser, in a pop-out. - */ -export function noteStoreCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void { - current?.noteCreated(client, accountId, ids); -} - export function startMailNotifications(initial: MailNotificationLabels): MailNotifications { let labels = initial; let batch: NewMail[] = []; @@ -176,9 +166,9 @@ export function startMailNotifications(initial: MailNotificationLabels): MailNot watch.stop(); if (timer) clearTimeout(timer); timer = null; - if (current === handle) current = null; + setStoreCreatedListener(null); }, }; - current = handle; + setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids)); return handle; } diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts index 84d5ccd58..44d429dfc 100644 --- a/lib/desktop/power.ts +++ b/lib/desktop/power.ts @@ -4,46 +4,24 @@ * changes and replaces a dead stream, the identities and policy refresh. Those are the * handlers already listening, so this only gives them the word. The hidden main window * of the desktop app never goes out of view, so without this they wouldn't hear it. - * - * The local store registers with addWakeHandler and addSleepHandler (setOnline, sync). + * The local store hangs on the same two: `online` is setOnline(true) and a flush, + * `visibilitychange` is a sync per account. It takes itself offline for a sleep through + * addSleepHandler, in lib/desktop/system-events.ts. */ import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events'; import { usePolicyStore } from '@/stores/policy-store'; -type Handler = () => void; - -const wakeHandlers = new Set(); -const sleepHandlers = new Set(); /** A resume and an unlock usually come together. */ const WAKE_REPEAT_MS = 2000; -export function addWakeHandler(handler: Handler): () => void { - wakeHandlers.add(handler); - return () => wakeHandlers.delete(handler); -} - -export function addSleepHandler(handler: Handler): () => void { - sleepHandlers.add(handler); - return () => sleepHandlers.delete(handler); -} - -function runAll(handlers: Set): void { - for (const handler of [...handlers]) { - try { - handler(); - } catch (error) { - console.error('A wake or sleep handler failed:', error); - } - } -} - function wake(): void { document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('online')); const policy = usePolicyStore.getState(); if (policy.loaded && !policy.fetching) void policy.fetchPolicy(); - runAll(wakeHandlers); + runWakeHandlers(); } export function startPowerWatch(): () => void { @@ -58,7 +36,7 @@ export function startPowerWatch(): () => void { }; const stopPower = bridge.onPower((event) => { if (event === 'resume' || event === 'unlock-screen') wakeOnce(); - else if (event === 'suspend') runAll(sleepHandlers); + else if (event === 'suspend') runSleepHandlers(); }); // Wi-Fi to cable is a new network with no offline moment in between. const connection = (navigator as unknown as { connection?: EventTarget }).connection; diff --git a/lib/desktop/system-events.ts b/lib/desktop/system-events.ts new file mode 100644 index 000000000..f1b258077 --- /dev/null +++ b/lib/desktop/system-events.ts @@ -0,0 +1,56 @@ +/** + * What the local store and the OS integration say to each other, with nothing imported + * either way: lib/offline runs in every browser, and the notification code pulls in half + * the app's stores. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +type Handler = () => void; +type CreatedListener = (client: IJMAPClient, accountId: string, ids: readonly string[]) => void; + +const wakeHandlers = new Set(); +const sleepHandlers = new Set(); +let createdListener: CreatedListener | null = null; + +export function addWakeHandler(handler: Handler): () => void { + wakeHandlers.add(handler); + return () => { + wakeHandlers.delete(handler); + }; +} + +export function addSleepHandler(handler: Handler): () => void { + sleepHandlers.add(handler); + return () => { + sleepHandlers.delete(handler); + }; +} + +function runAll(handlers: Set): void { + for (const handler of [...handlers]) { + try { + handler(); + } catch (error) { + console.error('A wake or sleep handler failed:', error); + } + } +} + +export function runWakeHandlers(): void { + runAll(wakeHandlers); +} + +export function runSleepHandlers(): void { + runAll(sleepHandlers); +} + +/** The notification path, while it runs. */ +export function setStoreCreatedListener(listener: CreatedListener | null): void { + createdListener = listener; +} + +/** The store's `created` event for mail. Does nothing in a browser or a pop-out. */ +export function reportStoreCreatedMail(client: IJMAPClient, accountId: string, ids: readonly string[]): void { + createdListener?.(client, accountId, ids); +} diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 836e1286b..beff3d52e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -60,6 +60,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from ' import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; +import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events'; export type OfflineNotice = | { kind: 'queued'; subject: string } @@ -180,6 +181,10 @@ const storeHooks: StorePageHooks = { notify({ kind: 'store-reset' }); }, onEvent(event) { + if (event.type === 'created' && event.collection === 'Email') { + const client = onlineClientFor(event.account); + if (client) reportStoreCreatedMail(client, event.jmap, event.ids); + } if (event.type === 'synced') { useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } })); forgetMailSnapshot(event.account); @@ -906,6 +911,11 @@ export function startOfflineMode(): () => void { window.removeEventListener('offline', onOffline); }); + // The desktop app going to sleep. Waking comes with the two events above and below. + cleanups.push(addSleepHandler(() => { + void getStore().setOnline(false).catch(() => {}); + })); + // Back in front: catch up now rather than at the next interval. const onVisible = () => { if (document.visibilityState !== 'visible') return;