fix: finish a wipe in the desktop app, which never answers about shown notifications

This commit is contained in:
Linus Rath
2026-11-04 03:12:43 +01:00
parent dc8482ace0
commit c844f2a5f3
5 changed files with 108 additions and 3 deletions
@@ -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<Notification[]>): 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<Notification[]>(() => {}));
const answer = await Promise.race([
closeSystemReminders(['a', 'b']).then(() => 'finished'),
new Promise((resolve) => setTimeout(() => resolve('hung'), 6_000)),
]);
expect(answer).toBe('finished');
}, 10_000);
});
@@ -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<voi
if (!worker) return;
try {
for (const key of keys) {
for (const shown of await worker.getNotifications({ tag: tagOf(key) })) shown.close();
const shown = await shownNotifications(worker, { tag: tagOf(key) });
if (!shown) return;
for (const notification of shown) notification.close();
}
} catch {
// Nothing that matters is left behind.
+24
View File
@@ -0,0 +1,24 @@
/**
* The notifications on screen, for closing them. Electron has no answer to
* `getNotifications()`: its promise stays pending for good, so nothing may wait on
* it without a limit. A wipe that did never got to the keys.
*/
const SHOWN_WAIT_MS = 1_000;
/** Null: no answer in time, and asking again won't get one. */
export async function shownNotifications(
registration: Pick<ServiceWorkerRegistration, 'getNotifications'>,
filter?: GetNotificationOptions,
waitMs = SHOWN_WAIT_MS,
): Promise<Notification[] | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
registration.getNotifications(filter),
new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), waitMs); }),
]);
} finally {
clearTimeout(timer);
}
}
+35
View File
@@ -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<Notification[]>(() => {}),
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<void>) => 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 });
+10 -2
View File
@@ -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<void> {
clipboard?: { clearFiles?: () => void | Promise<void> };
} | null;
bridge?.os?.clearSurfaces?.();
await bridge?.clipboard?.clearFiles?.();
let timer: ReturnType<typeof setTimeout> | 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<ServiceWorkerRegistration | null> {
/** A sender and a subject can sit on screen long after the mail is gone. */
async function closeShownNotifications(): Promise<void> {
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. */