diff --git a/lib/calendar-reminders/__tests__/system-notification.test.ts b/lib/calendar-reminders/__tests__/system-notification.test.ts new file mode 100644 index 000000000..761d401bb --- /dev/null +++ b/lib/calendar-reminders/__tests__/system-notification.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { closeSystemReminders } from '../system-notification'; + +describe('closing reminders', () => { + afterEach(() => { + vi.unstubAllGlobals(); + delete (navigator as { serviceWorker?: unknown }).serviceWorker; + }); + + function withRegistration(getNotifications: (filter: { tag: string }) => Promise): void { + vi.stubGlobal('Notification', { permission: 'granted' }); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ getNotifications }) }, + }); + } + + it('closes what is shown', async () => { + const close = vi.fn(); + withRegistration(async () => [{ close } as unknown as Notification]); + await closeSystemReminders(['a']); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("doesn't wait for good on a shell that never answers", async () => { + // Electron leaves getNotifications() pending for ever. + withRegistration(() => new Promise(() => {})); + const answer = await Promise.race([ + closeSystemReminders(['a', 'b']).then(() => 'finished'), + new Promise((resolve) => setTimeout(() => resolve('hung'), 6_000)), + ]); + expect(answer).toBe('finished'); + }, 10_000); +}); diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts index 2427f7c19..26dd022d1 100644 --- a/lib/calendar-reminders/system-notification.ts +++ b/lib/calendar-reminders/system-notification.ts @@ -5,6 +5,8 @@ * tab asks. */ +import { shownNotifications } from '@/lib/notifications/shown'; + export interface SystemReminder { key: string; title: string; @@ -65,7 +67,9 @@ export async function closeSystemReminders(keys: readonly string[]): Promise, + filter?: GetNotificationOptions, + waitMs = SHOWN_WAIT_MS, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + registration.getNotifications(filter), + new Promise((resolve) => { timer = setTimeout(() => resolve(null), waitMs); }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index d0b44dd22..bf460e7cf 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -1261,6 +1261,41 @@ describe('what else goes with a wipe', () => { }); }); +describe('a shell that never answers about shown notifications', () => { + // Electron has no answer to getNotifications(): the promise stays pending for good. + beforeEach(() => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + const registration = { + getNotifications: () => new Promise(() => {}), + pushManager: { getSubscription: async () => null }, + }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { getRegistration: async () => registration, controller: null } }); + }); + + afterEach(() => { + delete (navigator as { serviceWorker?: unknown }).serviceWorker; + }); + + const ended = (work: Promise) => Promise.race([ + work.then(() => 'finished'), + new Promise((resolve) => setTimeout(() => resolve('hung'), 20_000)), + ]); + + it('a wipe of the whole device still gets to the keys and the files', async () => { + const before = purge.files; + expect(await ended(wipe.wipeWholeDevice('user'))).toBe('finished'); + expect(purge.files).toBeGreaterThan(before); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + + it('a wipe of one account gets past them too, to its sign-out', async () => { + expect(await ended(wipe.wipeAccountFromDevice(ALICE, 'admin'))).toBe('finished'); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(useAccountStore.getState().accounts.some((account) => account.id === ALICE)).toBe(false); + }); +}); + describe('the session timers', () => { const timerSignOut = () => useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index faface568..74b2777bf 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -52,6 +52,7 @@ import { isAppLockedNow } from '@/lib/app-lock/state'; import { clearSealed, VAULT_DB } from '@/lib/app-lock/vault'; import { clearRescuedDrafts } from '@/lib/popout/rescued-drafts'; import { isPopoutWindow } from '@/lib/popout/window-role'; +import { shownNotifications } from '@/lib/notifications/shown'; import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; import { ACCOUNT_STORAGE_KEYS, onBeforeSignOut } from '@/lib/sign-out-cleanup'; import { @@ -101,6 +102,8 @@ export const CHECK_IN_EVERY_MS = 60 * 60 * 1000; const TICK_MS = 60 * 1000; /** How long a wipe waits to tell the instance before it goes on: it tries again at the next contact. */ const REPORT_BUDGET_MS = 3_000; +/** How long a wipe waits for the desktop app's main process: the keys come after this. */ +const SHELL_FILES_BUDGET_MS = 10_000; /** How long a wipe waits for one database a running plugin frame holds open. */ const DATABASE_BUDGET_MS = 2_000; @@ -208,7 +211,11 @@ async function clearShellFiles(): Promise { clipboard?: { clearFiles?: () => void | Promise }; } | null; bridge?.os?.clearSurfaces?.(); - await bridge?.clipboard?.clearFiles?.(); + let timer: ReturnType | undefined; + await Promise.race([ + Promise.resolve(bridge?.clipboard?.clearFiles?.()), + new Promise((resolve) => { timer = setTimeout(resolve, SHELL_FILES_BUDGET_MS); }), + ]).finally(() => clearTimeout(timer)); } /** A running frame is gone at the next load. Before any plugin starts again. */ @@ -256,7 +263,8 @@ async function pushRegistration(): Promise { /** A sender and a subject can sit on screen long after the mail is gone. */ async function closeShownNotifications(): Promise { const registration = await pushRegistration(); - for (const notification of await registration?.getNotifications?.() ?? []) notification.close(); + if (!registration?.getNotifications) return; + for (const notification of await shownNotifications(registration) ?? []) notification.close(); } /** The subscription is the browser's, shared by every account on this origin. */