mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: finish a wipe in the desktop app, which never answers about shown notifications
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user