diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index 7819c93d2..0410a0b70 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -30,6 +30,7 @@ import { AppUpdatePrompt } from "@/components/app-update-prompt";
import { OfflineHost } from "@/components/offline/offline-host";
import { DeviceWipeHost } from "@/components/offline/device-wipe-host";
import { NotificationActionsHost } from "@/components/providers/notification-actions-host";
+import { DesktopOsHost } from "@/components/desktop/desktop-os-host";
import { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
@@ -89,6 +90,7 @@ export default async function LocaleLayout({
+
{!IS_LITE && }
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index df771c490..6243bc78c 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -1520,9 +1520,10 @@ function LoginPageContent() {
)}
{refusedWaiting.map((wait) => {
const date = new Date(wait.until).toLocaleDateString(String(params?.locale ?? "en"), { dateStyle: "medium" });
+ const prefix = wait.kind === "session" ? "session_waiting" : "refused_waiting";
return (
- {wait.unsent === null ? tOfflineWipe("refused_waiting_unknown", { date }) : tOfflineWipe("refused_waiting", { count: wait.unsent, date })}
+ {wait.unsent === null ? tOfflineWipe(`${prefix}_unknown`, { date }) : tOfflineWipe(prefix, { count: wait.unsent, date })}
);
})}
diff --git a/app/(main)/admin/_pages/device-registry-panel.tsx b/app/(main)/admin/_pages/device-registry-panel.tsx
index db4ef63fa..eba321350 100644
--- a/app/(main)/admin/_pages/device-registry-panel.tsx
+++ b/app/(main)/admin/_pages/device-registry-panel.tsx
@@ -235,6 +235,7 @@ function wipeReasonLabel(reason: NonNullable): string
switch (reason) {
case 'user': return 'by the user';
case 'rejected': return 'the server refused the login';
+ case 'session': return 'the session limit ran out';
default: return reason;
}
}
diff --git a/app/api/devices/__tests__/route.test.ts b/app/api/devices/__tests__/route.test.ts
index 3d26acf90..055485d39 100644
--- a/app/api/devices/__tests__/route.test.ts
+++ b/app/api/devices/__tests__/route.test.ts
@@ -18,7 +18,7 @@ vi.mock('@/lib/admin/session', async (original) => ({
getClientIP: () => client.ip,
}));
-import { POST as checkIn, REGISTER_PER_HOUR } from '../route';
+import { POST as checkIn } from '../route';
import { POST as wiped } from '../wiped/route';
import { GET as adminList, POST as adminAct } from '../../admin/devices/route';
import * as registry from '@/lib/devices/registry';
@@ -120,12 +120,12 @@ describe('POST /api/devices', () => {
it('holds new rows to a much lower limit per address than check-ins, and a 429 changes nothing', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const statuses: number[] = [];
- for (let i = 0; i <= REGISTER_PER_HOUR; i += 1) {
+ for (let i = 0; i <= registry.REGISTER_PER_HOUR; i += 1) {
statuses.push((await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }))).status);
}
- expect(statuses.slice(0, REGISTER_PER_HOUR).every((status) => status === 200)).toBe(true);
+ expect(statuses.slice(0, registry.REGISTER_PER_HOUR).every((status) => status === 200)).toBe(true);
expect(statuses.at(-1)).toBe(429);
- expect((await registry.listDevices()).total).toBe(REGISTER_PER_HOUR);
+ expect((await registry.listDevices()).total).toBe(registry.REGISTER_PER_HOUR);
// A device that's already registered still hears about its wipe.
const [{ id }] = (await registry.listDevices()).devices;
diff --git a/app/api/devices/route.ts b/app/api/devices/route.ts
index 40748e1fe..3d3be4c91 100644
--- a/app/api/devices/route.ts
+++ b/app/api/devices/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClientIP } from '@/lib/admin/session';
-import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, registerDevice, TooManyDevicesError } from '@/lib/devices/registry';
+import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, REGISTER_PER_HOUR, registerDevice, TooManyDevicesError } from '@/lib/devices/registry';
import { logger } from '@/lib/logger';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStore } from '@/lib/shared-store';
@@ -11,12 +11,6 @@ const NO_STORE = { 'Cache-Control': 'no-store' };
const MAX_BODY_BYTES = 2048;
// Per address: a whole office can sit behind one, checking in as it starts up.
const PER_MINUTE = 300;
-/**
- * A new row is written to the state dir and stays half a year, for anyone with a
- * session. Enough for an office turning this on in one morning: a device turned
- * away tries again in a quarter of an hour.
- */
-export const REGISTER_PER_HOUR = 60;
function cleanText(value: unknown): string {
if (typeof value !== 'string') return '';
diff --git a/app/api/errors/__tests__/route.test.ts b/app/api/errors/__tests__/route.test.ts
index d53e1dde6..c32f23499 100644
--- a/app/api/errors/__tests__/route.test.ts
+++ b/app/api/errors/__tests__/route.test.ts
@@ -102,3 +102,22 @@ describe('POST /api/errors', () => {
expect((await POST(post('not json'))).status).toBe(400);
});
});
+
+describe('a desktop app\'s version', () => {
+ it.each(['0.3.0-beta.1+desktop', '1.20.0+desktop', '1.20.0-rc.2'])('keeps %s', async (version) => {
+ config.errorReportingDsn = DSN;
+ const res = await POST(post({ name: 'TypeError', frames: [], version }));
+ expect(res.status).toBe(204);
+ await flushErrors();
+ const day = (await readErrors())[utcDay()] ?? {};
+ expect(Object.values(day).map((entry) => entry.version)).toContain(version);
+ });
+
+ it.each(['1.20.0+a+b', '1.20.0-beta/../x', 'not a version'])('falls back to the server\'s for %s', async (version) => {
+ config.errorReportingDsn = DSN;
+ await POST(post({ name: 'RangeError', frames: [], version }));
+ await flushErrors();
+ const day = (await readErrors())[utcDay()] ?? {};
+ expect(Object.values(day).map((entry) => entry.version)).not.toContain(version);
+ });
+});
diff --git a/app/api/errors/route.ts b/app/api/errors/route.ts
index b31a38952..0bdb02235 100644
--- a/app/api/errors/route.ts
+++ b/app/api/errors/route.ts
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
'browser',
// The version the tab runs (a tab left open across an update runs the
// old one, and its frames point into that); the server's when absent.
- typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(),
+ typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:-[\w.-]{1,40})?(?:\+[\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(),
);
countBrowserError();
return new NextResponse(null, { status: 204, headers: NO_STORE });
diff --git a/components/admin/org/__tests__/offline-section.test.tsx b/components/admin/org/__tests__/offline-section.test.tsx
index dfcbe2415..2b5630630 100644
--- a/components/admin/org/__tests__/offline-section.test.tsx
+++ b/components/admin/org/__tests__/offline-section.test.tsx
@@ -61,12 +61,18 @@ describe('Offline mode section', () => {
it('leaves a refused login\'s data alone by default, and can ask for deletion at once, per scope', async () => {
await renderSection();
- const toggle = screen.getByTestId('org-devices-wipeOnRefusedLogin');
+ const toggle = screen.getByTestId('org-devices-wipeOnForcedSignOut');
expect(toggle.getAttribute('aria-checked')).toBe('false');
act(() => latest!.setScopeId('field'));
- fireEvent.click(screen.getByTestId('org-devices-wipeOnRefusedLogin'));
- expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnRefusedLogin: true } });
- expect(latest!.policy?.organisation?.devices.wipeOnRefusedLogin).toBe(false);
+ fireEvent.click(screen.getByTestId('org-devices-wipeOnForcedSignOut'));
+ expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnForcedSignOut: true } });
+ expect(latest!.policy?.organisation?.devices.wipeOnForcedSignOut).toBe(false);
+ });
+
+ it('says what a sign-out does now, for the timers too', async () => {
+ await renderSection();
+ expect(document.body.textContent).not.toContain('Every sign-out, the automatic one after inactivity included, deletes everything');
+ expect(document.body.textContent).toContain('A sign-out someone asks for deletes everything');
});
it('writes a scope override only into that scope', async () => {
diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx
index ed51c2708..9a806ccbc 100644
--- a/components/admin/org/sections/offline-section.tsx
+++ b/components/admin/org/sections/offline-section.tsx
@@ -50,7 +50,7 @@ const describeCap = (value: number, unit: string) => (value > 0 ? `${value} ${un
export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
const forbid = draft.orgValue('devices', 'noOffline');
const noAttachments = draft.orgValue('devices', 'noOfflineAttachments');
- const wipeOnRefused = draft.orgValue('devices', 'wipeOnRefusedLogin');
+ const wipeOnRefused = draft.orgValue('devices', 'wipeOnForcedSignOut');
return (
- draft.setOrgValue('devices', 'wipeOnRefusedLogin', v)} testId="org-devices-wipeOnRefusedLogin" />
- draft.clearOrgValue('devices', 'wipeOnRefusedLogin')} />
+ draft.setOrgValue('devices', 'wipeOnForcedSignOut', v)} testId="org-devices-wipeOnForcedSignOut" />
+ draft.clearOrgValue('devices', 'wipeOnForcedSignOut')} />
@@ -120,8 +120,8 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to
- them when it fetches the policy; the server can't check what is on a device. Every sign-out, the automatic one
- after inactivity included, deletes everything. With several accounts signed in, each account follows its own settings.
+ them when it fetches the policy; the server can't check what is on a device. A sign-out someone asks for deletes everything.
+ One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings.
Bulwark Lite reads the same policy but has no offline mode.{' '}
How offline mode works
diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx
new file mode 100644
index 000000000..2b4ed8af3
--- /dev/null
+++ b/components/calendar/missed-reminders-dialog.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useLocale, useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { Dialog } from "@/components/ui/dialog";
+import { getPathPrefix } from "@/lib/browser-navigation";
+import { reminderTimeZone, useMissedReminders } from "@/lib/calendar-reminders/state";
+import { dismissReminders, joinLabel, joinMeeting } from "@/lib/desktop/reminder-notifications";
+import { useSettingsStore } from "@/stores/settings-store";
+
+/** What came due while the app was closed, the machine slept or notifications were paused. */
+export function MissedRemindersDialog() {
+ const t = useTranslations("desktopReminders");
+ const tAlert = useTranslations("calendar.notifications");
+ const locale = useLocale();
+ const timeZone = useSettingsStore((s) => s.timeZone);
+ const account = useMissedReminders((s) => s.account);
+ const alerts = useMissedReminders((s) => s.alerts);
+ const close = useMissedReminders((s) => s.close);
+ const joinLabels = { join: t("join"), joinHost: (host: string) => t("join_host", { host }) };
+
+ if (!account || alerts.length === 0) return null;
+
+ const format = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: reminderTimeZone(timeZone) });
+ const finish = () => {
+ dismissReminders(account, alerts.map((alert) => alert.key));
+ close();
+ };
+
+ return (
+
+ );
+}
diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx
new file mode 100644
index 000000000..a6e522136
--- /dev/null
+++ b/components/desktop/desktop-os-host.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useLocale, useTranslations } from "next-intl";
+import { useArchiveActionEnabled } from "@/lib/archive-gate";
+import { getDesktopBridge } from "@/lib/desktop/bridge";
+import { isAppLocked } from "@/lib/desktop/app-lock-state";
+import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
+import { startOsSummary } from "@/lib/desktop/os-summary";
+import { startPowerWatch } from "@/lib/desktop/power";
+import { startWindowFiles } from "@/lib/desktop/window-files";
+import type { IJMAPClient } from "@/lib/jmap/client-interface";
+import { useAccountStore } from "@/stores/account-store";
+import { useAuthStore } from "@/stores/auth-store";
+
+/**
+ * The main window's end of the desktop app's OS integration: new mail as native
+ * notifications, what the tray and jump list show, waking up after sleep, and files
+ * dropped or pasted on the window. Renders nothing, and does nothing in a browser.
+ * Lives inside MainWindowOnly, so pop-outs don't do any of it a second time.
+ */
+export function DesktopOsHost() {
+ const t = useTranslations("desktopOs");
+ const tNotify = useTranslations("notifications");
+ const tPush = useTranslations("pwaNotify");
+ const locale = useLocale();
+ const archiveEnabled = useArchiveActionEnabled();
+ const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n"));
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const mailRef = useRef(null);
+ const summaryRef = useRef<{ refresh: () => void } | null>(null);
+ const localeRef = useRef(locale);
+ localeRef.current = locale;
+
+ const labels: MailNotificationLabels = {
+ newMail: t("new_mail"),
+ newMailCount: (count) => tNotify("new_mail_count", { count }),
+ noSubject: t("no_subject"),
+ archive: archiveEnabled ? tPush("action_archive") : null,
+ markRead: tPush("action_mark_read"),
+ delete: t("action_delete"),
+ reply: tPush("action_reply"),
+ replyPlaceholder: tPush("reply_placeholder"),
+ };
+ const labelsRef = useRef(labels);
+ labelsRef.current = labels;
+
+ useEffect(() => {
+ const os = getDesktopBridge()?.os;
+ if (!os) return;
+ // Main shows no content anywhere until this page has said it may.
+ os.setLocked(isAppLocked());
+ const mail = startMailNotifications(labelsRef.current);
+ mailRef.current = mail;
+ const summary = startOsSummary(() => localeRef.current);
+ summaryRef.current = summary;
+ const stopPower = startPowerWatch();
+ const stopFiles = startWindowFiles();
+ return () => {
+ mail.stop();
+ summary.stop();
+ stopPower();
+ stopFiles();
+ mailRef.current = null;
+ summaryRef.current = null;
+ };
+ }, []);
+
+ useEffect(() => {
+ mailRef.current?.setLabels(labels);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [locale, archiveEnabled]);
+
+ // Every login needs a state to work from before its first push.
+ useEffect(() => {
+ const mail = mailRef.current;
+ if (!mail || !isAuthenticated) return;
+ const auth = useAuthStore.getState();
+ const clients = new Set(useAccountStore.getState().accounts.flatMap((account) => auth.getClientForAccount(account.id) ?? []));
+ if (auth.client) clients.add(auth.client);
+ for (const client of clients) mail.seed(client);
+ summaryRef.current?.refresh();
+ }, [isAuthenticated, clientKey]);
+
+ return null;
+}
diff --git a/components/device/__tests__/device-gates.test.tsx b/components/device/__tests__/device-gates.test.tsx
index bb86a6b7b..5557f2eed 100644
--- a/components/device/__tests__/device-gates.test.tsx
+++ b/components/device/__tests__/device-gates.test.tsx
@@ -129,6 +129,8 @@ describe('No export', () => {
await waitFor(() => expect(discard).toHaveBeenCalledWith(true));
expect(controller.signal.aborted).toBe(true);
expect(useMailTransferStore.getState().job).toBeNull();
+ // The dialog just went: this is all that says why.
+ expect(toast.info).toHaveBeenCalledWith('export_blocked');
});
});
diff --git a/components/device/device-policy-host.tsx b/components/device/device-policy-host.tsx
index d7465be08..acfaa9c7c 100644
--- a/components/device/device-policy-host.tsx
+++ b/components/device/device-policy-host.tsx
@@ -4,7 +4,7 @@ import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl';
import { useDevicePolicy, useStrictestDevices } from '@/hooks/use-device-policy';
-import { DOWNLOAD_BLOCKED_EVENT, type DownloadKind } from '@/lib/download-guard';
+import { DOWNLOAD_BLOCKED_EVENT, downloadAllowed, type DownloadKind } from '@/lib/download-guard';
// Installs the download guard (lib/download-guard.ts) for every page.
import '@/lib/device-policy';
import { useMailTransferStore } from '@/stores/mail-transfer-store';
@@ -48,6 +48,8 @@ export function DevicePolicyHost() {
if (store.job?.kind !== 'export') return;
const job = store.job;
store.cancelJob('policy');
+ // This closes the dialog too, which would otherwise be gone with no reason given.
+ downloadAllowed('export', null);
// The private file goes at once, once the job has stopped: nothing may download it any more.
const discard = () => {
if (useMailTransferStore.getState().job?.controller === job.controller) useMailTransferStore.getState().close({ now: true });
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index db59d734e..5bb3b1821 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -174,6 +174,7 @@ import { getDesktopBridge } from "@/lib/desktop/bridge";
import { noteOpenDraft } from "@/lib/desktop/window-restore";
import type { RescuedDraft } from "@/lib/popout/channel";
import { toUnicodeEmail } from "@/lib/idn";
+import { setComposerFileTarget } from "@/lib/desktop/window-files";
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
@@ -2711,6 +2712,12 @@ export function EmailComposer({
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
// The plugin side panels fold when the composer gets too narrow for them.
const [composerRootEl, setComposerRootEl] = useState(null);
+
+ // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen.
+ useEffect(() => setComposerFileTarget(
+ (files) => void addFiles(files),
+ () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
+ ), [addFiles, composerRootEl]);
// The id plugins know this composer by (registered below, with the handle).
const [pluginComposerId, setPluginComposerId] = useState(null);
const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId);
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index f92e2926c..4f9c51111 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -679,6 +679,7 @@ interface DraggableAttachmentChipProps {
onPointerEnter: () => void;
onDragStart: (e: React.DragEvent) => void;
onDragEnd: (e: React.DragEvent) => void;
+ onCopyAsFile?: () => Promise;
}) => React.ReactNode;
}
@@ -686,6 +687,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
const source = useMemo(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
+ // A decrypted attachment doesn't go through the app's temporary folder or the clipboard: it
+ // stays with the browser's drag, which only writes it where the user drops it.
+ ...(attachment.decryptedAttachment ? {} : {
+ getBlob: async () => {
+ if (attachment.blobId && client) {
+ return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null);
+ }
+ const bytes = attachment.tnefData;
+ if (!bytes || bytes.byteLength === 0) return null;
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
+ return new Blob([buffer], { type: inertBlobType(attachment.type) });
+ },
+ }),
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx
index d04e16c09..167767905 100644
--- a/components/email/reader-attachments.tsx
+++ b/components/email/reader-attachments.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState, type DragEvent, type ReactNode } from "react";
import { useTranslations } from "next-intl";
-import { ChevronDown, ChevronUp, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
+import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -11,6 +11,7 @@ import { IconButton } from "@/components/ui/icon-button";
import { getAttachmentDisplayName } from "@/lib/attachment-name";
import { cn, formatFileSize } from "@/lib/utils";
import { noMediaSavingProps } from "@/lib/device-frame";
+import { toast } from "@/stores/toast-store";
import { attachmentIcon } from "./attachment-chips";
export interface ReaderAttachmentItem {
@@ -29,6 +30,8 @@ export interface AttachmentDragProps {
onPointerEnter: () => void;
onDragStart: (e: DragEvent) => void;
onDragEnd: (e: DragEvent) => void;
+ /** The desktop app only. */
+ onCopyAsFile?: () => Promise;
}
/** Past this many tiles the bar shows one row less and a "Show N more" tile. */
@@ -173,6 +176,7 @@ export function ReaderAttachments({
)}
{pluginMenu?.(attachment)}
+ {!downloadsOff && drag.onCopyAsFile && }
{!downloadsOff && (
({
)}
{pluginMenu?.(attachment)}
+ {!downloadsOff && drag.onCopyAsFile && }
{!downloadsOff && (
onDownload(attachment)}>
@@ -359,6 +364,25 @@ export function ReaderAttachments({
* The image itself when there is a thumbnail, else the file type's icon over
* its extension, in the type's colour.
*/
+/** The desktop app's "Copy as file", for pasting the attachment into Explorer or Finder. */
+function CopyAsFileButton({ copy }: { copy: () => Promise }) {
+ const t = useTranslations("desktopOs");
+ return (
+ {
+ void copy().then((ok) => {
+ if (ok) toast.success(t("copied"));
+ else toast.error(t("copy_failed"));
+ }, () => toast.error(t("copy_failed")));
+ }}
+ >
+
+
+ );
+}
+
function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) {
if (url) {
return
;
diff --git a/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx
new file mode 100644
index 000000000..ffb61683c
--- /dev/null
+++ b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx
@@ -0,0 +1,88 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// A refused account beside another that stays signed in: the switcher marks it,
+// with the count and the date, and takes the user to its sign-in.
+
+const push = vi.hoisted(() => vi.fn());
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => ({ push, replace: vi.fn() }),
+ usePathname: () => '/',
+}));
+
+const { AccountSwitcher } = await import('../account-switcher');
+const { useAccountStore } = await import('@/stores/account-store');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+const { markLoginRefused, setRefusedCount, dropRefusedLogin } = await import('@/lib/devices/wipe-state');
+const { useOfflineStore, markEvicted, clearEvicted } = await import('@/lib/offline/state');
+
+const A = 'a@example.com';
+const B = 'b@example.com';
+
+function account(id: string) {
+ return { id, label: id, email: id, username: id, displayName: id.toUpperCase(), serverUrl: 'https://mail.example.com', avatarColor: '#336699', cookieSlot: 0, isConnected: true, isDefault: id === A };
+}
+
+function open(): void {
+ render();
+ fireEvent.click(screen.getByTestId('account-switcher'));
+}
+
+beforeEach(() => {
+ push.mockClear();
+ localStorage.clear();
+ useAccountStore.setState({ accounts: [account(A)] } as never);
+ useAuthStore.setState({ activeAccountId: A } as never);
+ useSettingsStore.setState({ interfaceLayout: 'default' } as never);
+ useOfflineStore.setState({ enabled: [A, B] });
+ markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B });
+ markEvicted(B);
+});
+
+describe('an account that needs signing in again', () => {
+ it('is marked, with its count and the date its data goes', () => {
+ setRefusedCount(B, 2);
+ open();
+ const row = screen.getByTestId('account-needs-sign-in');
+ expect(row.textContent).toContain(B);
+ expect(row.textContent).toContain('needs_sign_in');
+ expect(row.textContent).toContain('switcher_waiting');
+ });
+
+ it('says only the date when it couldn\'t count, or there\'s nothing unsent', () => {
+ setRefusedCount(B, 0);
+ open();
+ const row = screen.getByTestId('account-needs-sign-in');
+ expect(row.textContent).toContain('switcher_kept');
+ expect(row.textContent).not.toContain('switcher_waiting');
+ });
+
+ it('takes the user to its sign-in, with the name filled in', () => {
+ setRefusedCount(B, 2);
+ open();
+ fireEvent.click(screen.getByTestId('account-needs-sign-in'));
+ expect(push).toHaveBeenCalledWith(`/login?mode=add-account&username=${encodeURIComponent(B)}`);
+ });
+
+ it('shows the count as soon as it\'s known, with the menu open', () => {
+ open();
+ expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_kept');
+ act(() => setRefusedCount(B, 3));
+ expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_waiting');
+ });
+
+ it('isn\'t shown to an account that wasn\'t there', () => {
+ useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never);
+ useAccountStore.setState({ accounts: [account('stranger@example.com')] } as never);
+ open();
+ expect(screen.queryByTestId('account-needs-sign-in')).toBeNull();
+ });
+
+ it('is gone once it signed in again', () => {
+ clearEvicted(B);
+ open();
+ expect(screen.queryByTestId('account-needs-sign-in')).toBeNull();
+ dropRefusedLogin(B);
+ });
+});
diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx
index 505f868e6..9c424eabe 100644
--- a/components/layout/account-switcher.tsx
+++ b/components/layout/account-switcher.tsx
@@ -17,6 +17,7 @@ import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread";
import { isDocumentRTL } from "@/i18n/direction";
import { cn, formatFileSize } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
+import { onRefusedLoginsChanged, siblingWaits, waitDate } from "@/lib/devices/wipe-state";
import { Avatar } from "@/components/ui/avatar";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -116,6 +117,11 @@ export function AccountSwitcher({
const removeAccount = useAuthStore((s) => s.removeAccount);
const logoutAll = useAuthStore((s) => s.logoutAll);
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
+ const tWipe = useTranslations("offlineWipe");
+ // An account that signed out by itself beside this one: its data waits.
+ const [, noteWaitsChanged] = useState(0);
+ useEffect(() => onRefusedLoginsChanged(() => noteWaitsChanged((count) => count + 1)), []);
+ const waiting = siblingWaits(activeAccountId);
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
@@ -513,6 +519,30 @@ export function AccountSwitcher({
);
})}
+ {waiting.map((wait) => (
+
+ ))}
{/* Mountain View: the account actions as rows closing the white card,
diff --git a/components/mail-transfer/__tests__/local-export.test.tsx b/components/mail-transfer/__tests__/local-export.test.tsx
new file mode 100644
index 000000000..4d25a4cbe
--- /dev/null
+++ b/components/mail-transfer/__tests__/local-export.test.tsx
@@ -0,0 +1,838 @@
+import 'fake-indexeddb/auto';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host';
+import { localExportAllowed, startLocalExport } from '@/components/mail-transfer/start-local-export';
+import { OfflineSettings } from '@/components/offline/offline-settings';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { getStrictestDevices, devicesForAccount } from '@/lib/device-policy';
+import { setDownloadGuard } from '@/lib/download-guard';
+import type { ExportProgress } from '@/lib/mail-export/engine';
+import { memorySink, type ExportFile, type ExportSink } from '@/lib/mail-export/sinks';
+import { useOfflineStore } from '@/lib/offline/state';
+import { runWipeCleaners } from '@/lib/offline/wipe-registry';
+import { useAccountSubjects } from '@/lib/org/account-subjects';
+import { runBeforeSignOut } from '@/lib/sign-out-cleanup';
+import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { isJobActive, openLocalExport, useMailTransferStore } from '@/stores/mail-transfer-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { toast } from '@/stores/toast-store';
+
+const FOLDERS: { id: string; name: string; role?: string; sortOrder: number }[] = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0 },
+ { id: 'archive', name: 'Archive', sortOrder: 1 },
+];
+const RAW = new TextEncoder().encode('Subject: x\r\n\r\nbody\r\n');
+
+/** A kept account where Inbox has 2 of 3 originals and Archive 1 of 1. */
+function fakeStore(overrides: { rawBlob?: (blobId: string) => unknown; status?: { total: number; kept: number }; size?: { bytes: number; keptBytes: number } } = {}) {
+ const area = vi.fn(async (_account: string, name: string, params?: { mailboxId?: string; blobId?: string }): Promise => {
+ if (name === 'mail.session') return { primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-a' } };
+ if (name === 'mail.rawStatus') return overrides.status ?? (params?.mailboxId === 'inbox' ? { total: 3, kept: 2 } : { total: 1, kept: 1 });
+ if (name === 'mail.rawBlob') return overrides.rawBlob ? overrides.rawBlob(params!.blobId!) : { ok: true, blob: new Blob([RAW]) };
+ if (name === 'mail.rawSize') return overrides.size ?? { bytes: 10, keptBytes: 10 };
+ if (name === 'mail.rawKept') return [];
+ return null;
+ });
+ const answer = vi.fn(async (_account: string, calls: [string, Record, string][]) => {
+ const [name, args] = calls[0];
+ if (name === 'Mailbox/get') return { methodResponses: [['Mailbox/get', { list: FOLDERS }, '0']] };
+ if (name === 'Email/query') return { methodResponses: [['Email/query', { ids: (args.position as number) > 0 ? [] : ['m1'] }, '0']] };
+ return { methodResponses: [['Email/get', { list: [{ id: 'm1', blobId: 'raw-m1', size: RAW.length, keywords: {}, receivedAt: '2026-10-01T00:00:00Z', subject: 'x' }] }, '0']] };
+ });
+ return { supported: () => true, setScope: vi.fn(async () => {}), area, answer };
+}
+
+function setDevices(devices: Partial) {
+ usePolicyStore.setState({
+ loaded: true, source: 'server', partial: false, subject: { email: 'alice@example.org', groups: [] },
+ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
+ } as never);
+}
+
+function fakeSink(): ExportSink & { write: ReturnType; abort: ReturnType; close: ReturnType } {
+ const file: ExportFile = { name: 'a.mbox', size: 1, discard: vi.fn(async () => {}) };
+ return { kind: 'picker', write: vi.fn(async () => {}), close: vi.fn(async () => file), abort: vi.fn(async () => {}) };
+}
+
+/** The one lib/device-policy.ts installs, for tests that put their own in for a moment. */
+const installedGuard: Parameters[0] = (kind, subject) => {
+ const devices = subject === undefined ? getStrictestDevices() : devicesForAccount(subject as string | null);
+ return kind === 'export' ? !devices.noExport : !devices.noDownloads;
+};
+
+const realClient = { getServerUrl: () => 'https://mail.example.org', getAccountId: () => 'jmap-a', getMaxConcurrentRequests: () => 4 };
+
+function setOnline(online: boolean) {
+ useOfflineStore.setState({ browserOffline: !online, downServers: [] });
+}
+
+beforeEach(() => {
+ useMailTransferStore.setState({ request: null, visible: false, job: null });
+ useAuthStore.setState({
+ client: realClient as never,
+ activeAccountId: 'acc',
+ getClientForAccount: ((id: string) => (id === 'acc' ? realClient : undefined)) as never,
+ getAllConnectedClients: (() => new Map([['acc', realClient]])) as never,
+ } as never);
+ useAccountStore.setState({ accounts: [{ id: 'acc', email: 'alice@example.org', username: 'alice@example.org', serverUrl: 'https://mail.example.org' }] } as never);
+ useOfflineStore.setState({
+ enabled: ['acc', 'other'], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], syncing: [], lastSyncedAt: {}, choices: { acc: { on: true } }, health: {},
+ storageFull: {}, progress: {}, persist: 'granted', browserOffline: false, downServers: [], startedOffline: false,
+ usage: { acc: { bytes: 5000, parts: [] } },
+ });
+ setDevices({});
+ setStoreBackend(fakeStore() as unknown as StoreBackend);
+});
+
+afterEach(() => {
+ cleanup();
+ setStoreBackend(null);
+ delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker;
+ delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
+ vi.unstubAllGlobals();
+});
+
+async function openDialog() {
+ render();
+ act(() => openLocalExport('acc'));
+ await screen.findByTestId('local-export-summary');
+}
+
+/** Runs one export to its end through the job. */
+async function finishedJob(sink = fakeSink()) {
+ expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: ['Inbox'] }], sink })).toBe(true);
+ await act(async () => { await useMailTransferStore.getState().job!.finished; });
+ return sink;
+}
+
+describe('before an export starts', () => {
+ it('says how many originals are here and how many come from the server when online', async () => {
+ await openDialog();
+ expect(screen.getByTestId('local-export-kept').textContent).toBe('kept');
+ expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online');
+ expect(screen.queryByTestId('local-export-reason')).toBeNull();
+ });
+
+ it('says offline how many can\'t be exported, and why', async () => {
+ setOnline(false);
+ await openDialog();
+ expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_offline');
+ expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_not_kept');
+ });
+
+ it('gives attachments being off as the reason when they are', async () => {
+ setOnline(false);
+ useOfflineStore.setState({ choices: { acc: { on: true, attachments: false } } } as never);
+ await openDialog();
+ expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_attachments_off');
+ });
+
+ it('counts only the folders chosen, and offers none of the rest', async () => {
+ setOnline(false);
+ await openDialog();
+ const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType;
+ expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', { jmap: 'jmap-a', mailboxId: 'inbox' });
+ const boxes = screen.getAllByTestId('local-export-folder') as HTMLInputElement[];
+ expect(boxes.map((box) => box.dataset.folder)).toEqual(['Inbox', 'Archive']);
+ fireEvent.click(boxes[0]);
+ // Only Archive now: all of its originals are here.
+ expect(screen.queryByTestId('local-export-missing')).toBeNull();
+ const all = screen.getByTestId('local-export-all') as HTMLInputElement;
+ expect(all.indeterminate).toBe(true);
+ fireEvent.click(all);
+ expect(boxes.every((box) => box.checked)).toBe(true);
+ });
+
+ it('can\'t start offline with no original of anything, and can as soon as the server is back', async () => {
+ setOnline(false);
+ setStoreBackend(fakeStore({ status: { total: 4, kept: 0 } }) as unknown as StoreBackend);
+ await openDialog();
+ expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true);
+ expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_offline');
+ act(() => setOnline(true));
+ expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false);
+ expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online');
+ });
+
+ it('puts the focus on the folders once they are read', async () => {
+ await openDialog();
+ await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId('local-export-all')));
+ });
+
+ it('can\'t start with nothing chosen', async () => {
+ await openDialog();
+ fireEvent.click(screen.getByTestId('local-export-all'));
+ expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true);
+ expect(screen.getByTestId('local-export-nothing')).toBeTruthy();
+ });
+
+ it('opens the picker from the click, with a name for what was chosen', async () => {
+ const writable = { write: vi.fn(async () => {}), close: vi.fn(async () => {}), abort: vi.fn(async () => {}) };
+ const picker = vi.fn(async () => ({ createWritable: async () => writable }));
+ (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker;
+ await openDialog();
+ fireEvent.click(screen.getAllByTestId('local-export-folder')[1]);
+ fireEvent.click(screen.getByTestId('local-export-start'));
+ await waitFor(() => expect(picker).toHaveBeenCalled());
+ expect(picker).toHaveBeenCalledWith(expect.objectContaining({ suggestedName: 'Inbox.mbox' }));
+ await waitFor(() => expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'));
+ expect(writable.close).toHaveBeenCalled();
+ });
+});
+
+describe('the device policy "No export"', () => {
+ it('locks the action in settings, with the reason beside it', () => {
+ setDevices({ noExport: true });
+ render();
+ expect((screen.getByTestId('local-export-open') as HTMLButtonElement).disabled).toBe(true);
+ expect(screen.getByText('settings_label').closest('[data-search-label]')!.querySelector('[data-org-locked]')).toBeTruthy();
+ });
+
+ it('leaves the action in settings open when it\'s off, and that opens the dialog', async () => {
+ render();
+ fireEvent.click(screen.getByTestId('local-export-open'));
+ expect(useMailTransferStore.getState().request).toEqual({ kind: 'local-export', account: 'acc' });
+ });
+
+ it('keeps the dialog from opening', () => {
+ setDevices({ noExport: true });
+ act(() => openLocalExport('acc'));
+ expect(useMailTransferStore.getState().request).toBeNull();
+ });
+
+ it('refuses to start, gives back the sink and reads nothing', () => {
+ const store = fakeStore();
+ setStoreBackend(store as unknown as StoreBackend);
+ setDevices({ noExport: true });
+ const sink = fakeSink();
+ expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(false);
+ expect(sink.abort).toHaveBeenCalled();
+ expect(store.answer).not.toHaveBeenCalled();
+ expect(useMailTransferStore.getState().job).toBeNull();
+ });
+
+ it('counts as on while the organisation\'s word isn\'t known, as at a first start offline', () => {
+ usePolicyStore.setState({ loaded: false, source: 'none' } as never);
+ expect(localExportAllowed('acc')).toBe(false);
+ usePolicyStore.setState({ loaded: true, source: 'fallback' } as never);
+ expect(localExportAllowed('acc')).toBe(false);
+ usePolicyStore.setState({ loaded: true, source: 'server', partial: true } as never);
+ expect(localExportAllowed('acc')).toBe(false);
+ usePolicyStore.setState({ loaded: true, source: 'cache', partial: false } as never);
+ expect(localExportAllowed('acc')).toBe(true);
+ });
+
+ it('stops a running export the moment it\'s switched on, and its file goes', async () => {
+ let release: () => void = () => {};
+ const store = fakeStore();
+ store.answer.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); }));
+ setStoreBackend(store as unknown as StoreBackend);
+ const sink = fakeSink();
+ startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink });
+ const job = useMailTransferStore.getState().job!;
+ expect(isJobActive(job)).toBe(true);
+ await waitFor(() => expect(store.answer).toHaveBeenCalled());
+ act(() => setDevices({ noExport: true }));
+ expect(job.controller.signal.aborted).toBe(true);
+ release();
+ await act(async () => { await job.finished; });
+ expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled');
+ expect(sink.abort).toHaveBeenCalled();
+ expect(sink.close).not.toHaveBeenCalled();
+ });
+
+ it('asks about the account the export is for, not the active one', async () => {
+ useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never);
+ useAccountSubjects.setState({ settled: { other: true } } as never);
+ const asked: unknown[] = [];
+ setDownloadGuard((_kind, subject) => { asked.push(subject); return true; });
+ try {
+ const sink = fakeSink();
+ expect(startLocalExport({ account: 'other', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true);
+ expect(useMailTransferStore.getState().job?.scope).toBe('other');
+ await act(async () => { await useMailTransferStore.getState().job!.finished; });
+ expect(sink.close).toHaveBeenCalled();
+ expect(asked.length).toBeGreaterThan(1);
+ expect(new Set(asked)).toEqual(new Set(['other']));
+ } finally {
+ setDownloadGuard(installedGuard);
+ }
+ });
+
+ it('stops at the file when it came on without the store noticing, as the save picker has no other gate', async () => {
+ const sink = fakeSink();
+ let allowed = true;
+ setDownloadGuard(() => allowed);
+ try {
+ sink.write.mockImplementation(async () => { allowed = false; });
+ await finishedJob(sink);
+ expect(useMailTransferStore.getState().job?.progress.phase).toBe('failed');
+ expect(sink.close).not.toHaveBeenCalled();
+ expect(sink.abort).toHaveBeenCalled();
+ } finally {
+ setDownloadGuard(installedGuard);
+ }
+ });
+
+ it('shows the organisation as the reason when a real change of policy stopped it', async () => {
+ const sink = fakeSink();
+ render();
+ act(() => openLocalExport('acc'));
+ // Through the policy store, the way a fetched policy arrives.
+ sink.write.mockImplementation(async () => { act(() => setDevices({ noExport: true })); });
+ await finishedJob(sink);
+ expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled');
+ expect(screen.getByTestId('local-export-stop-reason').textContent).toBe('export_blocked');
+ expect(sink.close).not.toHaveBeenCalled();
+ });
+
+ it('says why when the account stopped keeping mail here during the run', async () => {
+ const sink = fakeSink();
+ render();
+ act(() => openLocalExport('acc'));
+ sink.write.mockImplementation(async () => { act(() => useOfflineStore.setState({ enabled: ['other'] })); });
+ await finishedJob(sink);
+ expect(screen.getByTestId('local-export-stop-reason').textContent).toBe('stopped_offline_off');
+ });
+
+ it('gives no reason beyond the title when the person cancelled it', async () => {
+ const sink = fakeSink();
+ render();
+ act(() => openLocalExport('acc'));
+ sink.write.mockImplementation(async () => { act(() => useMailTransferStore.getState().cancelJob()); });
+ await finishedJob(sink);
+ expect(screen.queryByTestId('local-export-stop-reason')).toBeNull();
+ });
+
+ describe('with a scope the subject decides', () => {
+ /** Everyone may export; one domain or group may not. */
+ function scoped(match: Record) {
+ const base = usePolicyStore.getState().rawPolicy;
+ usePolicyStore.setState({
+ rawPolicy: { ...base, scopes: [{ id: 'locked', label: 'Locked', match, policy: { organisation: { devices: { noExport: true } } } }] },
+ loaded: true, source: 'server', partial: false, subject: null, subjectPending: false,
+ } as never);
+ }
+
+ afterEach(() => {
+ usePolicyStore.setState({ rawPolicy: { ...usePolicyStore.getState().rawPolicy, scopes: [] }, subjectPending: false } as never);
+ usePolicyStore.getState().setPolicySubject(null);
+ });
+
+ it.each([['domain', { domains: ['example.org'] }], ['group', { groups: ['team@example.org'] }]])('%s: says no until the subject is known, whatever Everyone may do', (_kind, match) => {
+ scoped(match as Record);
+ // What the policy alone would say: nobody has been matched yet.
+ expect(devicesForAccount('acc').noExport).toBe(false);
+ expect(localExportAllowed('acc')).toBe(false);
+ usePolicyStore.setState({ subjectPending: true } as never);
+ expect(localExportAllowed('acc')).toBe(false);
+ usePolicyStore.setState({ subjectPending: false } as never);
+ usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: ['team@example.org'] });
+ expect(devicesForAccount('acc').noExport).toBe(true);
+ expect(localExportAllowed('acc')).toBe(false);
+ });
+
+ it('opens once the subject is known and the scope isn\'t for it', () => {
+ scoped({ domains: ['elsewhere.example'] });
+ expect(localExportAllowed('acc')).toBe(false);
+ usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] });
+ expect(localExportAllowed('acc')).toBe(true);
+ });
+
+ it('waits for another account\'s own subject too', () => {
+ usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] });
+ useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never);
+ useAccountSubjects.setState({ settled: {} } as never);
+ expect(localExportAllowed('other')).toBe(false);
+ useAccountSubjects.setState({ settled: { other: true } } as never);
+ expect(localExportAllowed('other')).toBe(true);
+ });
+ });
+
+ describe('at the click on Export', () => {
+ function withPicker() {
+ const picker = vi.fn(async () => ({ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }) }));
+ (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker;
+ return picker;
+ }
+
+ it('opens the picker in the same tick as the click, so the gesture counts', async () => {
+ const picker = withPicker();
+ await openDialog();
+ fireEvent.click(screen.getByTestId('local-export-start'));
+ expect(picker).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'));
+ });
+
+ it('opens no picker under the policy, and says why', async () => {
+ const picker = withPicker();
+ await openDialog();
+ setDevices({ noExport: true });
+ fireEvent.click(screen.getByTestId('local-export-start'));
+ expect(picker).not.toHaveBeenCalled();
+ expect((await screen.findByTestId('local-export-refused')).textContent).toBe('export_blocked');
+ expect(useMailTransferStore.getState().job).toBeNull();
+ });
+
+ it('opens no picker while the policy isn\'t known, and says that', async () => {
+ const picker = withPicker();
+ await openDialog();
+ usePolicyStore.setState({ subjectPending: true } as never);
+ fireEvent.click(screen.getByTestId('local-export-start'));
+ expect(picker).not.toHaveBeenCalled();
+ expect((await screen.findByTestId('local-export-refused')).textContent).toBe('policy_unknown');
+ usePolicyStore.setState({ subjectPending: false } as never);
+ });
+ });
+});
+
+describe('what the browser can hold', () => {
+ const SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15';
+ const MB = 1024 * 1024;
+
+ /** A browser with no save picker but a private file system: the spool path. */
+ function spoolBrowser(agent: string, free: number) {
+ vi.stubGlobal('navigator', {
+ ...navigator, userAgent: agent,
+ storage: { getDirectory: async () => ({}), estimate: async () => ({ quota: free + 100, usage: 100 }) },
+ locks: { request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb({ name }) },
+ });
+ vi.stubGlobal('FileSystemFileHandle', class { createWritable() {} });
+ }
+
+ it('on WebKit stops it before it starts when the choice is over the cap, and says what to do', async () => {
+ spoolBrowser(SAFARI, 100 * 1024 * MB);
+ setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 600 * MB, keptBytes: 600 * MB } }) as unknown as StoreBackend);
+ await openDialog();
+ expect(screen.getByTestId('local-export-over-limit').textContent).toBe('over_limit');
+ expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true);
+ // Fewer folders fit.
+ fireEvent.click(screen.getAllByTestId('local-export-folder')[0]);
+ expect(screen.queryByTestId('local-export-over-limit')).toBeNull();
+ expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it('puts no such cap on Firefox', async () => {
+ spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 100 * 1024 * MB);
+ setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 600 * MB, keptBytes: 600 * MB } }) as unknown as StoreBackend);
+ await openDialog();
+ expect(screen.queryByTestId('local-export-over-limit')).toBeNull();
+ expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it('says so when the browser has no room for the spool', async () => {
+ spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 5 * MB);
+ setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 3 * MB, keptBytes: 3 * MB } }) as unknown as StoreBackend);
+ await openDialog();
+ expect(screen.getByTestId('local-export-no-room').textContent).toBe('no_room');
+ expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true);
+ });
+
+ it('counts only what will be written: offline, what is kept', async () => {
+ spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 5 * MB);
+ setOnline(false);
+ setStoreBackend(fakeStore({ status: { total: 2, kept: 1 }, size: { bytes: 300 * MB, keptBytes: 1 * MB } }) as unknown as StoreBackend);
+ await openDialog();
+ expect(screen.queryByTestId('local-export-no-room')).toBeNull();
+ act(() => setOnline(true));
+ expect(screen.getByTestId('local-export-no-room')).toBeTruthy();
+ });
+
+ it('doesn\'t ask about room when the save picker writes elsewhere', async () => {
+ spoolBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36', 5 * MB);
+ (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = vi.fn();
+ setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 300 * MB, keptBytes: 300 * MB } }) as unknown as StoreBackend);
+ await openDialog();
+ expect(screen.queryByTestId('local-export-no-room')).toBeNull();
+ expect(screen.queryByTestId('local-export-over-limit')).toBeNull();
+ });
+
+ it.each([
+ ['ExportTooLargeError', 'stopped_limit'],
+ ['QuotaExceededError', 'stopped_no_room'],
+ ['TypeError', 'failed_generic'],
+ ])('says %s in the user\'s language, never the browser\'s text', async (name, key) => {
+ const sink = fakeSink();
+ sink.write.mockImplementation(async () => { throw Object.assign(new Error('Some English from a browser'), { name, limit: 1024 }); });
+ render();
+ act(() => openLocalExport('acc'));
+ await finishedJob(sink);
+ expect(useMailTransferStore.getState().job?.progress.phase).toBe('failed');
+ expect(screen.getByText(key)).toBeTruthy();
+ expect(screen.queryByText(/Some English/)).toBeNull();
+ });
+
+ it('says a message failed in the user\'s language too', async () => {
+ setStoreBackend(fakeStore({ rawBlob: () => { throw new Error('Some English from a server'); } }) as unknown as StoreBackend);
+ render();
+ act(() => openLocalExport('acc'));
+ await finishedJob();
+ expect(screen.getByText(/message_failed/)).toBeTruthy();
+ expect(screen.queryByText(/Some English/)).toBeNull();
+ });
+});
+
+describe('what an export couldn\'t write', () => {
+ const notKept = () => setStoreBackend(fakeStore({ rawBlob: () => ({ ok: false, reason: 'not_kept' }) }) as unknown as StoreBackend);
+
+ it('counts it offline with the reason and no way to retry until online', async () => {
+ setOnline(false);
+ notKept();
+ render();
+ act(() => openLocalExport('acc'));
+ await finishedJob();
+ expect((useMailTransferStore.getState().job?.progress as ExportProgress).notKept).toEqual({ not_kept: 1 });
+ expect(screen.getByTestId('local-export-not-kept-missing').textContent).toBe('not_kept_not_kept');
+ // Nothing was written: no file, and nothing to download again.
+ expect(screen.queryByTestId('mail-export-file')).toBeNull();
+ expect(screen.queryByTestId('mail-export-download')).toBeNull();
+ expect(screen.queryByTestId('local-export-not-kept-attachments')).toBeNull();
+ expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_offline');
+ expect((screen.getByTestId('local-export-retry') as HTMLButtonElement).disabled).toBe(true);
+ });
+
+ it('offers the retry once the connection is back, which goes back to the question with fresh counts', async () => {
+ setOnline(false);
+ notKept();
+ render();
+ act(() => openLocalExport('acc'));
+ await finishedJob();
+ act(() => setOnline(true));
+ expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_online');
+ const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType;
+ area.mockClear();
+ fireEvent.click(screen.getByTestId('local-export-retry'));
+ await screen.findByTestId('local-export-summary');
+ expect(useMailTransferStore.getState().job).toBeNull();
+ expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', expect.anything());
+ expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online');
+ });
+
+ it('says the export stopped, not that a file is ready, when it ends hidden with nothing written', async () => {
+ setOnline(false);
+ notKept();
+ const warning = vi.spyOn(toast, 'warning');
+ const success = vi.spyOn(toast, 'success');
+ render();
+ await finishedJob();
+ expect(warning).toHaveBeenCalledWith('toast_stopped', expect.anything());
+ expect(success).not.toHaveBeenCalled();
+ warning.mockRestore();
+ success.mockRestore();
+ });
+
+ it('says when the picked file couldn\'t be removed after a run that wrote nothing', async () => {
+ setOnline(false);
+ notKept();
+ const sink = fakeSink();
+ sink.abort.mockImplementation(async () => ({ leftover: 'a.mbox' }));
+ render();
+ act(() => openLocalExport('acc'));
+ await finishedJob(sink);
+ expect(sink.close).not.toHaveBeenCalled();
+ expect(screen.getByTestId('local-export-leftover').textContent).toBe('leftover_file');
+ });
+
+ it('says nothing about a file that was removed', async () => {
+ setOnline(false);
+ notKept();
+ const sink = fakeSink();
+ sink.abort.mockImplementation(async () => ({}));
+ render();
+ act(() => openLocalExport('acc'));
+ await finishedJob(sink);
+ expect(sink.abort).toHaveBeenCalled();
+ expect(screen.queryByTestId('local-export-leftover')).toBeNull();
+ });
+
+ it('keeps to the chosen folders on the retry, also when a new folder has turned up', async () => {
+ setOnline(false);
+ notKept();
+ render();
+ act(() => openLocalExport('acc'));
+ await screen.findByTestId('local-export-summary');
+ fireEvent.click(screen.getAllByTestId('local-export-folder')[1]);
+ await finishedJob();
+ act(() => setOnline(true));
+ FOLDERS.push({ id: 'new', name: 'New', sortOrder: 2 });
+ try {
+ fireEvent.click(screen.getByTestId('local-export-retry'));
+ await waitFor(() => expect(screen.getAllByTestId('local-export-folder')).toHaveLength(3));
+ const boxes = screen.getAllByTestId('local-export-folder') as HTMLInputElement[];
+ expect(boxes.map((box) => [box.dataset.folder, box.checked])).toEqual([['Inbox', true], ['Archive', false], ['New', false]]);
+ } finally {
+ FOLDERS.pop();
+ }
+ });
+
+ it('takes a new folder in when all were chosen', async () => {
+ setOnline(false);
+ notKept();
+ render();
+ act(() => openLocalExport('acc'));
+ await screen.findByTestId('local-export-summary');
+ await finishedJob();
+ act(() => setOnline(true));
+ FOLDERS.push({ id: 'new', name: 'New', sortOrder: 2 });
+ try {
+ fireEvent.click(screen.getByTestId('local-export-retry'));
+ await waitFor(() => expect(screen.getAllByTestId('local-export-folder')).toHaveLength(3));
+ expect((screen.getAllByTestId('local-export-folder') as HTMLInputElement[]).every((box) => box.checked)).toBe(true);
+ } finally {
+ FOLDERS.pop();
+ }
+ });
+
+ it('makes no empty file when the folders turned out empty by the time it ran, and says so', async () => {
+ const store = fakeStore();
+ store.answer.mockImplementation(async (_account: string, calls: [string, Record, string][]) => (
+ calls[0][0] === 'Mailbox/get'
+ ? { methodResponses: [['Mailbox/get', { list: FOLDERS }, '0']] }
+ : { methodResponses: [['Email/query', { ids: [] }, '0']] }
+ ) as never);
+ setStoreBackend(store as unknown as StoreBackend);
+ const sink = fakeSink();
+ render();
+ act(() => openLocalExport('acc'));
+ await finishedJob(sink);
+ expect(sink.close).not.toHaveBeenCalled();
+ expect(sink.abort).toHaveBeenCalled();
+ expect(screen.getByTestId('local-export-wrote-nothing').textContent).toBe('nothing_kept');
+ });
+
+ it('offers no retry when everything was written', async () => {
+ render();
+ act(() => openLocalExport('acc'));
+ await finishedJob();
+ expect(useMailTransferStore.getState().job?.progress.phase).toBe('done');
+ expect(screen.queryByTestId('local-export-retry')).toBeNull();
+ });
+});
+
+describe('while it runs', () => {
+ it('shows the progress, and Cancel stops it and gives up its file', async () => {
+ const sink = fakeSink();
+ let finish: () => void = () => {};
+ const store = fakeStore();
+ store.answer.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); }));
+ setStoreBackend(store as unknown as StoreBackend);
+ render();
+ act(() => openLocalExport('acc'));
+ act(() => { startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink }); });
+ expect(screen.getByRole('progressbar')).toBeTruthy();
+ expect(screen.getByTestId('mail-export-status').getAttribute('role')).toBe('status');
+ await waitFor(() => expect(store.answer).toHaveBeenCalled());
+ const job = useMailTransferStore.getState().job!;
+ fireEvent.click(screen.getByTestId('local-export-cancel'));
+ expect(job.controller.signal.aborted).toBe(true);
+ finish();
+ await act(async () => { await job.finished; });
+ expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled');
+ expect(sink.abort).toHaveBeenCalled();
+ expect(sink.close).not.toHaveBeenCalled();
+ expect(screen.getByText('cancelled_title')).toBeTruthy();
+ });
+});
+
+describe('stopped while the file is being finished', () => {
+ /** A sink whose close doesn't end until told, the way opening a large spool takes a while. */
+ function slowClose() {
+ const sink = fakeSink();
+ const file: ExportFile = { name: 'a.mbox', size: 1, discard: vi.fn(async () => {}) };
+ let finish: () => void = () => {};
+ let signal: AbortSignal | undefined;
+ sink.close.mockImplementation((given?: AbortSignal) => new Promise((resolve) => {
+ signal = given;
+ finish = () => resolve(file);
+ }));
+ return { sink, file, finish: () => finish(), signal: () => signal };
+ }
+
+ async function closing(sink: ExportSink) {
+ expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true);
+ await waitFor(() => expect((sink.close as ReturnType)).toHaveBeenCalled());
+ return useMailTransferStore.getState().job!;
+ }
+
+ it('passes the job\'s signal to the sink', async () => {
+ const slow = slowClose();
+ const job = await closing(slow.sink);
+ expect(slow.signal()).toBe(job.controller.signal);
+ slow.finish();
+ await act(async () => { await job.finished; });
+ });
+
+ it('a cancel: the file goes and the run counts as cancelled, also if the sink finished anyway', async () => {
+ const slow = slowClose();
+ const job = await closing(slow.sink);
+ act(() => useMailTransferStore.getState().cancelJob());
+ slow.finish();
+ await act(async () => { await job.finished; });
+ const progress = useMailTransferStore.getState().job?.progress as ExportProgress;
+ expect(progress.phase).toBe('cancelled');
+ expect(progress.file).toBeUndefined();
+ expect(slow.file.discard).toHaveBeenCalledWith(true);
+ });
+
+ it('a wipe that outlasts its wait: the job and its file still go when it ends', async () => {
+ render();
+ const slow = slowClose();
+ const job = await closing(slow.sink);
+ await act(async () => { await runWipeCleaners('acc'); });
+ expect(job.controller.signal.aborted).toBe(true);
+ // Still running after the 1.5 s the wipe gives it.
+ expect(useMailTransferStore.getState().job).not.toBeNull();
+ slow.finish();
+ await act(async () => { await job.finished; });
+ await waitFor(() => expect(useMailTransferStore.getState().job).toBeNull());
+ expect(slow.file.discard).toHaveBeenCalledWith(true);
+ });
+
+ it('a sign-out that outlasts its wait: the same', async () => {
+ render();
+ const slow = slowClose();
+ const job = await closing(slow.sink);
+ await act(async () => { await runBeforeSignOut('acc'); });
+ slow.finish();
+ await act(async () => { await job.finished; });
+ await waitFor(() => expect(useMailTransferStore.getState().job).toBeNull());
+ expect(slow.file.discard).toHaveBeenCalledWith(true);
+ });
+
+ it('a wipe after the dialog closed drops the finished file at once', async () => {
+ const revoke = vi.fn();
+ vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: revoke }));
+ render();
+ await finishedJob(memorySink('a.mbox', 'application/mbox') as ReturnType);
+ act(() => useMailTransferStore.getState().close());
+ expect(useMailTransferStore.getState().job).toBeNull();
+ expect(revoke).not.toHaveBeenCalled();
+ await act(async () => { await runWipeCleaners('acc'); });
+ expect(revoke).toHaveBeenCalledWith('blob:x');
+ });
+});
+
+describe('an export is cleaned up', () => {
+ /** A job that doesn't end until it's aborted. */
+ function runningJob(account = 'acc') {
+ const sink = fakeSink();
+ const store = fakeStore();
+ // Like a store read: under way until the job is stopped.
+ store.answer.mockImplementation(() => new Promise((_resolve, reject) => {
+ const signal = useMailTransferStore.getState().job!.controller.signal;
+ const stop = () => reject(new DOMException('stopped', 'AbortError'));
+ if (signal.aborted) stop();
+ else signal.addEventListener('abort', stop);
+ }));
+ setStoreBackend(store as unknown as StoreBackend);
+ expect(startLocalExport({ account, jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true);
+ return { sink, job: useMailTransferStore.getState().job! };
+ }
+
+ function stubSweep() {
+ const removed: string[] = [];
+ const dir = {
+ keys: async function* () { yield 'old-spool'; },
+ removeEntry: async (name: string) => { removed.push(name); },
+ };
+ const root = {
+ getDirectoryHandle: async () => dir,
+ removeEntry: vi.fn(async () => {}),
+ };
+ vi.stubGlobal('navigator', { ...navigator, storage: { getDirectory: async () => root }, locks: { request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb({ name }) } });
+ return { removed, root };
+ }
+
+ it('on a wipe of its account: stops the job and forgets it', async () => {
+ render();
+ const { job } = runningJob();
+ await act(async () => { await runWipeCleaners('acc'); });
+ expect(job.controller.signal.aborted).toBe(true);
+ expect(useMailTransferStore.getState().job).toBeNull();
+ });
+
+ it('a wipe of another account leaves the job alone', async () => {
+ render();
+ const { job } = runningJob();
+ await act(async () => { await runWipeCleaners('someone-else'); });
+ expect(job.controller.signal.aborted).toBe(false);
+ expect(useMailTransferStore.getState().job).toBe(job);
+ });
+
+ it('on a wipe of everything: also every spool, whoever holds it', async () => {
+ render();
+ const { job } = runningJob();
+ const { root } = stubSweep();
+ await act(async () => { await runWipeCleaners(null); });
+ expect(job.controller.signal.aborted).toBe(true);
+ expect(root.removeEntry).toHaveBeenCalledWith('bulwark-exports', { recursive: true });
+ });
+
+ it('on a wipe of one account: the spools nobody holds go too', async () => {
+ render();
+ const { removed, root } = stubSweep();
+ await act(async () => { await runWipeCleaners('acc'); });
+ expect(removed).toEqual(['old-spool']);
+ expect(root.removeEntry).not.toHaveBeenCalled();
+ });
+
+ it('on sign-out of its account: stops the job and forgets it', async () => {
+ render();
+ const { job } = runningJob();
+ await act(async () => { await runBeforeSignOut('acc'); });
+ expect(job.controller.signal.aborted).toBe(true);
+ expect(useMailTransferStore.getState().job).toBeNull();
+ });
+
+ it('frees a finished export\'s file at once on a wipe', async () => {
+ render();
+ const sink = fakeSink();
+ await finishedJob(sink);
+ const file = (useMailTransferStore.getState().job!.progress as ExportProgress).file!;
+ await act(async () => { await runWipeCleaners('acc'); });
+ expect(file.discard).toHaveBeenCalledWith(true);
+ });
+
+ it('when the account stops keeping mail here: stops the job', () => {
+ const { job } = runningJob();
+ act(() => useOfflineStore.setState({ enabled: ['other'] }));
+ expect(job.controller.signal.aborted).toBe(true);
+ });
+
+ it('at start-up: sweeps what a crash left', async () => {
+ const { removed } = stubSweep();
+ render();
+ await waitFor(() => expect(removed).toEqual(['old-spool']));
+ });
+});
+
+describe('the taskbar in the desktop app', () => {
+ it('shows a running export and takes it away at the end', async () => {
+ const setProgress = vi.fn();
+ (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } };
+ render();
+ await finishedJob();
+ const calls = setProgress.mock.calls;
+ expect(calls[0]).toEqual([0, 'normal']);
+ expect(calls[calls.length - 1][0]).toBeNull();
+ });
+
+ it('says when it\'s paused', async () => {
+ const setProgress = vi.fn();
+ (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } };
+ render();
+ const store = useMailTransferStore.getState();
+ act(() => store.startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'paused', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } }));
+ expect(setProgress).toHaveBeenLastCalledWith(0.25, 'paused');
+ });
+
+ it('asks nothing of a shell without it, or of a browser', async () => {
+ (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
+ render();
+ await finishedJob();
+ expect(useMailTransferStore.getState().job?.progress.phase).toBe('done');
+ });
+});
diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx
new file mode 100644
index 000000000..c72d05329
--- /dev/null
+++ b/components/mail-transfer/local-export-dialog.tsx
@@ -0,0 +1,340 @@
+"use client";
+
+import { useEffect, useId, useRef, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
+import { Dialog } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { downloadAllowed } from "@/lib/download-guard";
+import { folderTree, safeName, type ExportFolder, type ExportFormat, type ExportProgress } from "@/lib/mail-export/engine";
+import { MEMORY_EXPORT_LIMIT, openExportSink, spoolLimit } from "@/lib/mail-export/sinks";
+import { scopeForAccount } from "@/lib/offline/offline-mode";
+import { useOfflineStore } from "@/lib/offline/state";
+import { getStore } from "@/lib/store/client/api";
+import { formatFileSize } from "@/lib/utils";
+import { useAccountStore } from "@/stores/account-store";
+import { isJobActive, useMailTransferStore, type LocalExportRequest } from "@/stores/mail-transfer-store";
+import { ExportProgressBody, saveMode } from "./mail-export-dialog";
+import { localExportAllowed, localExportDecided, localExportReachable, localExportReader, primaryJmapAccount, startLocalExport } from "./start-local-export";
+import { useFocusOnStage } from "./transfer-progress";
+
+interface FolderRow {
+ folder: ExportFolder;
+ total: number;
+ kept: number;
+ bytes: number;
+ keptBytes: number;
+}
+
+/** `free`: what the browser says is left for this site; null where it doesn't say. */
+type Loaded = { jmap: string; rows: FolderRow[]; free: number | null };
+
+function today(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+/** What an account keeps, folder by folder, for the question before an export starts. */
+async function readFolders(account: string): Promise {
+ const jmap = await primaryJmapAccount(account);
+ if (!jmap) throw new Error("The account has no mail account in the store");
+ const folders = folderTree(await localExportReader(account, jmap).getMailboxes());
+ const rows: FolderRow[] = [];
+ for (const folder of folders) {
+ const params = { jmap, mailboxId: folder.mailboxId };
+ const status = await getStore().area<{ total: number; kept: number }>(account, "mail.rawStatus", params);
+ const size = await getStore().area<{ bytes: number; keptBytes: number }>(account, "mail.rawSize", params);
+ rows.push({ folder, ...status, ...size });
+ }
+ return { jmap, rows, free: await freeRoom() };
+}
+
+async function freeRoom(): Promise {
+ try {
+ const estimate = await navigator.storage?.estimate?.();
+ if (typeof estimate?.quota !== "number") return null;
+ return Math.max(0, estimate.quota - (estimate.usage ?? 0));
+ } catch {
+ return null;
+ }
+}
+
+/** Settings > Backup & reset > Offline > Export kept mail: from the local store, also with no server. */
+export function LocalExportDialog({ request }: { request: LocalExportRequest }) {
+ const t = useTranslations("offlineExport");
+ const tExport = useTranslations("mail_export");
+ const tPolicy = useTranslations("device_policy");
+ const locale = useLocale();
+ const id = useId();
+ const { account } = request;
+ const visible = useMailTransferStore((s) => s.visible);
+ const job = useMailTransferStore((s) => s.job);
+ const close = useMailTransferStore((s) => s.close);
+ const hide = useMailTransferStore((s) => s.hide);
+ const cancelJob = useMailTransferStore((s) => s.cancelJob);
+ const address = useAccountStore((s) => {
+ const entry = s.accounts.find((candidate) => candidate.id === account);
+ return entry?.email || entry?.username || account;
+ });
+ // Re-renders when the connection changes; what it means is asked below.
+ useOfflineStore((s) => s.browserOffline);
+ useOfflineStore((s) => s.downServers);
+ const reachable = localExportReachable(account);
+ const [loaded, setLoaded] = useState(null);
+ const [loadFailed, setLoadFailed] = useState(false);
+ const [excluded, setExcluded] = useState>(new Set());
+ const [format, setFormat] = useState("mbox");
+ const [starting, setStarting] = useState(false);
+ const [refused, setRefused] = useState(null);
+ const mode = saveMode();
+ const progress = job?.kind === "export" ? job.progress : null;
+ const active = isJobActive(job);
+ const stageButton = useRef(null);
+ const allFolders = useRef(null);
+ const knownFolders = useRef>(new Set());
+ useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup");
+ const inSetup = !progress;
+
+ // Read again each time the setup shows, so a try online sees what's kept by then.
+ useEffect(() => {
+ if (!inSetup) return;
+ let live = true;
+ setLoadFailed(false);
+ readFolders(account).then(
+ (result) => {
+ if (!live) return;
+ // A folder that turned up since isn't one the person chose: after a try online
+ // only "All folders" takes it in.
+ const ids = result.rows.map((row) => row.folder.mailboxId);
+ const known = knownFolders.current;
+ knownFolders.current = new Set(ids);
+ setExcluded((was) => (was.size === 0 ? was : new Set([...was, ...ids.filter((folderId) => !known.has(folderId))])));
+ setLoaded(result);
+ },
+ () => { if (live) setLoadFailed(true); },
+ );
+ return () => { live = false; };
+ }, [account, inSetup]);
+
+ // The dialog opens before the folders are read, with only its footer to focus.
+ const foldersShown = inSetup && loaded !== null;
+ useEffect(() => {
+ if (foldersShown) allFolders.current?.focus();
+ }, [foldersShown]);
+
+ /** Why a run stopped, in the user's language, whatever the error said. */
+ function stopMessage(run: ExportProgress): string {
+ if (run.errorCode === "ExportForbiddenError" || !localExportAllowed(account)) return tPolicy("export_blocked");
+ if (run.errorCode === "ExportTooLargeError") return t("stopped_limit", { limit: formatFileSize(run.errorLimit ?? 0) });
+ if (run.errorCode === "QuotaExceededError") return t("stopped_no_room");
+ return t("failed_generic");
+ }
+
+ if (!visible) return null;
+
+ if (progress) {
+ const notKept = progress.notKept;
+ const notKeptCount = (notKept?.attachments_off ?? 0) + (notKept?.not_kept ?? 0);
+ const reason: unknown = job?.controller.signal.reason;
+ const leftover = progress.leftover;
+ const shown: ExportProgress = {
+ ...progress,
+ // Nothing from a server or a browser reaches the user in English.
+ failures: progress.failures.map((failure) => ({ ...failure, reason: t("message_failed") })),
+ ...(progress.phase === "failed" ? { error: stopMessage(progress) } : {}),
+ };
+ return (
+
+ );
+ }
+
+ const rows = loaded?.rows ?? [];
+ const chosen = rows.filter((row) => !excluded.has(row.folder.mailboxId));
+ const total = chosen.reduce((sum, row) => sum + row.total, 0);
+ const kept = chosen.reduce((sum, row) => sum + row.kept, 0);
+ const missing = total - kept;
+ const single = chosen.length === 1;
+ const attachmentsOff = !scopeForAccount(account).scope.attachments;
+ // Offline with no original of anything: the save picker would ask where to put nothing.
+ const nothingToWrite = total === 0 || (kept === 0 && !reachable);
+ // What the run will need, against what this browser can give it. Asked up front: a
+ // tab killed with no message after hours is the outcome to avoid.
+ const needed = chosen.reduce((sum, row) => sum + (reachable ? row.bytes : row.keptBytes), 0);
+ const limit = mode === "memory" ? MEMORY_EXPORT_LIMIT : mode === "download" ? spoolLimit() : Infinity;
+ const overLimit = needed > limit;
+ const noRoom = mode === "download" && loaded?.free != null && needed > loaded.free;
+
+ const onStart = async () => {
+ if (!loaded || starting || chosen.length === 0) return;
+ // Before the picker, with nothing awaited, so the click still counts for it.
+ if (!localExportAllowed(account)) {
+ setRefused(localExportDecided(account) ? tPolicy("export_blocked") : t("policy_unknown"));
+ downloadAllowed("export", account);
+ return;
+ }
+ setRefused(null);
+ const zip = !single || format === "eml";
+ const base = single
+ ? safeName(chosen[0].folder.path[chosen[0].folder.path.length - 1] ?? "", "mail")
+ : `${safeName(address, "mail")}-${today()}`;
+ setStarting(true);
+ try {
+ // The save picker must open straight from the click. In the desktop app it's the OS's own Save dialog.
+ const sink = await openExportSink(`${base}.${zip ? "zip" : "mbox"}`, zip ? "application/zip" : "application/mbox", zip ? ".zip" : ".mbox");
+ if (!sink) return;
+ startLocalExport({ account, jmap: loaded.jmap, format, folders: chosen.map((row) => row.folder), sink });
+ } finally {
+ setStarting(false);
+ }
+ };
+
+ const toggleFolder = (mailboxId: string, on: boolean) => {
+ const next = new Set(excluded);
+ if (on) next.delete(mailboxId);
+ else next.add(mailboxId);
+ setExcluded(next);
+ };
+
+ const formats: { value: ExportFormat; label: string; hint: string }[] = [
+ { value: "mbox", label: single ? tExport("format_mbox_folder") : tExport("format_mbox_account"), hint: tExport("hint_mbox") },
+ { value: "eml", label: single ? tExport("format_eml_folder") : tExport("format_eml_account"), hint: tExport("hint_eml") },
+ ];
+
+ return (
+
+ );
+}
diff --git a/components/mail-transfer/mail-export-dialog.tsx b/components/mail-transfer/mail-export-dialog.tsx
index c3e7b6b99..9c09d14b7 100644
--- a/components/mail-transfer/mail-export-dialog.tsx
+++ b/components/mail-transfer/mail-export-dialog.tsx
@@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { isJobActive, useMailTransferStore, type ExportRequest } from "@/stores/mail-transfer-store";
import type { ExportFormat, ExportProgress } from "@/lib/mail-export/engine";
import { safeName } from "@/lib/mail-export/engine";
-import { canPickSaveFile, canWritePrivateFile, memorySink, pickSaveFile, privateFileSink, sweepPrivateExports, type ExportSink } from "@/lib/mail-export/sinks";
+import { canPickSaveFile, canWritePrivateFile, openExportSink } from "@/lib/mail-export/sinks";
import { formatFileSize } from "@/lib/utils";
import { formatClock, TransferBar, useFocusOnStage } from "./transfer-progress";
import { startExport } from "./start-export";
@@ -19,7 +19,7 @@ function today(): string {
}
/** Where the file will go in this browser, said before the export starts. */
-function saveMode(): "picker" | "download" | "memory" {
+export function saveMode(): "picker" | "download" | "memory" {
if (canPickSaveFile()) return "picker";
return canWritePrivateFile() ? "download" : "memory";
}
@@ -57,21 +57,10 @@ export function MailExportDialog({ request }: { request: ExportRequest }) {
const name = `${base}.${zip ? "zip" : "mbox"}`;
const mime = zip ? "application/zip" : "application/mbox";
setStarting(true);
- let sink: ExportSink | null = null;
try {
// The save picker must open straight from the click.
- if (mode === "picker") {
- try {
- sink = await pickSaveFile(name, mime, zip ? ".zip" : ".mbox");
- } catch (error) {
- if ((error as { name?: string } | null)?.name === "AbortError") return;
- sink = null;
- }
- }
- if (!sink) {
- await sweepPrivateExports();
- sink = (await privateFileSink(name, mime)) ?? memorySink(name, mime);
- }
+ const sink = await openExportSink(name, mime, zip ? ".zip" : ".mbox");
+ if (!sink) return;
startExport({
format,
accountId: mailbox?.accountId || client.getAccountId(),
@@ -165,7 +154,7 @@ export function MailExportDialog({ request }: { request: ExportRequest }) {
type T = ReturnType;
-function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) {
+export function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) {
const running = p.phase === "running" || p.phase === "paused";
// The live region says only what changes the user's plans (paused, done);
// the count beside it would otherwise be read out several times a second.
diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx
index 981fb2bdf..5f9296947 100644
--- a/components/mail-transfer/mail-transfer-host.tsx
+++ b/components/mail-transfer/mail-transfer-host.tsx
@@ -4,18 +4,47 @@ import { useEffect, useRef } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Loader2, Pause } from "@/components/icons";
import { isJobActive, useMailTransferStore } from "@/stores/mail-transfer-store";
+import { showTaskbarProgress } from "@/lib/desktop/taskbar-progress";
+import { onDeviceWipe } from "@/lib/offline/wipe-registry";
import { onBeforeSignOut } from "@/lib/sign-out-cleanup";
import { clearCheckpointsFor } from "@/lib/mail-import/checkpoints";
-import { sweepPrivateExports } from "@/lib/mail-export/sinks";
+import { dropExportFiles, sweepPrivateExports } from "@/lib/mail-export/sinks";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { MailImportDialog } from "./mail-import-dialog";
import { MailExportDialog } from "./mail-export-dialog";
+import { LocalExportDialog } from "./local-export-dialog";
import { formatClock } from "./transfer-progress";
/** How long sign-out waits for a cancelled job to stop (its budget is 2 s). */
const SIGN_OUT_WAIT_MS = 1500;
+/**
+ * Stops the job of an account that goes, and frees what its export kept in this
+ * browser. `all`: whoever's job it is. Another account's job goes on.
+ */
+async function stopJobOf(accountId: string): Promise {
+ // Finished exports waiting out their grace period, with the dialog long closed.
+ dropExportFiles(accountId);
+ const store = useMailTransferStore.getState();
+ const job = store.job;
+ if (!job || !(accountId === "all" || !job.scope || job.scope === accountId)) return;
+ const forget = () => {
+ const now = useMailTransferStore.getState();
+ if (now.job?.controller === job.controller) now.close({ now: true });
+ };
+ if (isJobActive(job)) {
+ store.cancelJob("signout");
+ await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]);
+ // Still going after the wait: close() leaves a running job be, so it goes when it ends.
+ if (isJobActive(useMailTransferStore.getState().job)) {
+ void job.finished?.then(forget, forget);
+ return;
+ }
+ }
+ forget();
+}
+
/**
* Draws the mail import and export dialogs, and while it is hidden with a job running,
* a chip in the corner that shows the progress and brings it back. Mounted
@@ -31,18 +60,16 @@ export function MailTransferHost() {
// Another account signing out leaves the job alone. The job's checkpoint
// goes after the job has stopped, so a late write can't bring it back.
useEffect(() => onBeforeSignOut(async (accountId) => {
- const store = useMailTransferStore.getState();
- const job = store.job;
- if (job && (accountId === "all" || !job.scope || job.scope === accountId)) {
- if (isJobActive(job)) {
- store.cancelJob("signout");
- await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]);
- }
- useMailTransferStore.getState().close({ now: true });
- }
+ await stopJobOf(accountId);
if (accountId !== "all") await clearCheckpointsFor(accountId);
}), []);
+ // A wipe takes the same, and every spool no tab holds. `null` is everything on this device.
+ useEffect(() => onDeviceWipe(async (account) => {
+ await stopJobOf(account ?? "all");
+ await sweepPrivateExports({ force: account === null });
+ }), []);
+
// Export files a closed tab or a crash left in the browser's private
// storage go when the app starts and when someone signs in.
const activeAccountId = useAuthStore((s) => s.activeAccountId);
@@ -50,6 +77,20 @@ export function MailTransferHost() {
if (!isJobActive(useMailTransferStore.getState().job)) void sweepPrivateExports();
}, [activeAccountId]);
+ // The desktop app shows a running export on its taskbar button.
+ const exportPhase = job?.kind === "export" ? job.progress.phase : null;
+ const exportFraction = job?.kind === "export" ? job.progress.fraction : 0;
+ const onTaskbar = useRef(false);
+ useEffect(() => {
+ if (exportPhase === "running" || exportPhase === "paused") {
+ onTaskbar.current = true;
+ showTaskbarProgress(exportFraction, exportPhase === "paused" ? "paused" : "normal");
+ } else if (onTaskbar.current) {
+ onTaskbar.current = false;
+ showTaskbarProgress(null);
+ }
+ }, [exportPhase, exportFraction]);
+
// A job that ends while its dialog is hidden says so, with a way back to its summary.
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
@@ -64,8 +105,8 @@ export function MailTransferHost() {
if (job.kind === "import") {
if (phase === "done") toast.success(tImport("toast_done", { imported: job.progress.imported, failed: job.progress.failed }), { action });
else toast.warning(tImport("toast_stopped"), { action });
- } else if (phase === "done") {
- toast.success(tExport("toast_done", { name: job.progress.file?.name ?? "" }), { action });
+ } else if (phase === "done" && job.progress.file) {
+ toast.success(tExport("toast_done", { name: job.progress.file.name }), { action });
} else {
toast.warning(tExport("toast_stopped"), { action });
}
@@ -77,6 +118,7 @@ export function MailTransferHost() {
<>
{request?.kind === "import" && }
{request?.kind === "export" && }
+ {request?.kind === "local-export" && }
{!visible && isJobActive(job) && }
>
);
diff --git a/components/mail-transfer/start-local-export.ts b/components/mail-transfer/start-local-export.ts
new file mode 100644
index 000000000..ca40ea492
--- /dev/null
+++ b/components/mail-transfer/start-local-export.ts
@@ -0,0 +1,116 @@
+import { downloadAllowed, downloadAllowedQuietly } from "@/lib/download-guard";
+import { localExportClient } from "@/lib/mail-export/local-store-client";
+import { runExport, type ExportFolder, type ExportFormat } from "@/lib/mail-export/engine";
+import { guardedSink, type ExportSink } from "@/lib/mail-export/sinks";
+import { scopeDecided } from "@/lib/offline/offline-mode";
+import { isOfflineNow, useOfflineStore } from "@/lib/offline/state";
+import { getStore } from "@/lib/store/client/api";
+import type { StoreSession } from "@/lib/store/engine/transport";
+import { useAuthStore } from "@/stores/auth-store";
+import { useMailTransferStore } from "@/stores/mail-transfer-store";
+import { usePolicyStore } from "@/stores/policy-store";
+import { useAccountSubjects } from "@/lib/org/account-subjects";
+
+const MAIL_CAPABILITY = "urn:ietf:params:jmap:mail";
+
+/**
+ * Whether the organisation has said what applies to this account, down to its
+ * groups. A start offline may not have heard it, and a group or domain scope may
+ * forbid what Everyone may do: not known means no.
+ */
+export function localExportDecided(account: string): boolean {
+ return scopeDecided(account);
+}
+
+/** Whether the organisation lets this account export. */
+export function localExportAllowed(account: string): boolean {
+ return localExportDecided(account) && downloadAllowedQuietly("export", account);
+}
+
+function clientOf(account: string) {
+ const auth = useAuthStore.getState();
+ return auth.getClientForAccount?.(account) ?? (account === auth.activeAccountId ? auth.client : undefined) ?? null;
+}
+
+/** The real client while its server can be reached. */
+function reachableClient(account: string) {
+ const client = clientOf(account);
+ if (!client || isOfflineNow(client.getServerUrl())) return null;
+ return client;
+}
+
+/** Whether a message with no original here could be fetched right now. */
+export function localExportReachable(account: string): boolean {
+ return reachableClient(account) !== null;
+}
+
+/** The account's own mail account in the store. Shared accounts aren't offered yet. */
+export async function primaryJmapAccount(account: string): Promise {
+ const session = await getStore().area(account, "mail.session");
+ return session?.primaryAccounts?.[MAIL_CAPABILITY] ?? clientOf(account)?.getAccountId() ?? null;
+}
+
+export function localExportReader(account: string, jmap: string) {
+ return localExportClient({ account, jmap, store: getStore(), online: () => reachableClient(account) });
+}
+
+export interface StartLocalExportInput {
+ account: string;
+ jmap: string;
+ format: ExportFormat;
+ folders: ExportFolder[];
+ sink: ExportSink;
+}
+
+/** Runs an export from the local store as the dialog's job. False, and nothing read, when the policy says no. */
+export function startLocalExport(input: StartLocalExportInput): boolean {
+ const { account, jmap, format, folders } = input;
+ if (!localExportAllowed(account) || !downloadAllowed("export", account)) {
+ void input.sink.abort().catch(() => {});
+ return false;
+ }
+ const controller = new AbortController();
+ // Stops at once, also for an account that isn't the active one, and when no write is due.
+ const unwatchPolicy = usePolicyStore.subscribe(() => {
+ if (!localExportAllowed(account)) controller.abort("policy");
+ });
+ // What another account's groups turn out to be can say no as well.
+ const unwatchSubjects = useAccountSubjects.subscribe(() => {
+ if (!localExportAllowed(account)) controller.abort("policy");
+ });
+ const unwatchOffline = useOfflineStore.subscribe((state) => {
+ if (!state.enabled.includes(account)) controller.abort("offline-off");
+ });
+ let ended: () => void = () => {};
+ const finished = new Promise((resolve) => { ended = resolve; });
+ useMailTransferStore.getState().startJob({
+ kind: "export",
+ controller,
+ scope: account,
+ finished,
+ progress: { phase: "running", exported: 0, failed: 0, failures: [], total: 0, fraction: 0 },
+ });
+ void (async () => {
+ try {
+ const result = await runExport({
+ client: localExportReader(account, jmap),
+ accountId: jmap,
+ folders,
+ format,
+ // The save picker passes the guard in saveBlobAs, so every write asks.
+ sink: guardedSink(input.sink, () => localExportAllowed(account)),
+ signal: controller.signal,
+ requestsPerMinute: Infinity,
+ noEmptyFile: true,
+ onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress),
+ });
+ useMailTransferStore.getState().updateProgress(result);
+ } finally {
+ unwatchPolicy();
+ unwatchSubjects();
+ unwatchOffline();
+ ended();
+ }
+ })();
+ return true;
+}
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index b4bd25746..c6d045964 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -148,6 +148,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
+import { setMailLinkOpener } from "@/lib/desktop/mail-links";
import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state";
import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff";
@@ -2955,6 +2956,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}, []);
+ // The desktop app's tray, jump list and toasts, like any other permalink. A live
+ // composer's text goes through its own "Save or discard?" first.
+ useEffect(() => {
+ return setMailLinkOpener((link, options) => {
+ const open = () => void applyMailDeepLinkRef.current(link, options);
+ const requestClose = composerRequestCloseRef.current;
+ if (link.kind === 'message' && link.reply && composerOpenRef.current && requestClose) requestClose(open);
+ else open();
+ });
+ }, []);
+
// A plugin opening a message (api.ui.openEmail), as a click on a search
// result entry naming one does: handleEmailSelect, set once it is defined.
// Not inside the Pro shell, which keeps this tab mounted while hidden and
diff --git a/components/offline/__tests__/device-wipe-host.test.tsx b/components/offline/__tests__/device-wipe-host.test.tsx
new file mode 100644
index 000000000..f2cf3373e
--- /dev/null
+++ b/components/offline/__tests__/device-wipe-host.test.tsx
@@ -0,0 +1,62 @@
+import { act, render } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// The one toast when an account beside this one signs out by itself.
+
+const warning = vi.hoisted(() => vi.fn());
+vi.mock('@/stores/toast-store', () => ({ toast: { warning } }));
+vi.mock('@/lib/offline/device-wipe', () => ({ startDeviceWipe: () => () => {} }));
+
+const { DeviceWipeHost } = await import('../device-wipe-host');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { markLoginRefused, setRefusedCount } = await import('@/lib/devices/wipe-state');
+const { useOfflineStore, markEvicted } = await import('@/lib/offline/state');
+
+const A = 'a@example.com';
+const B = 'b@example.com';
+
+beforeEach(() => {
+ warning.mockClear();
+ localStorage.clear();
+ useOfflineStore.setState({ enabled: [A, B] });
+ useAuthStore.setState({ activeAccountId: A, isAuthenticated: true } as never);
+});
+
+function refuseB(): void {
+ markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B });
+ markEvicted(B);
+}
+
+describe('the toast', () => {
+ it('comes once, when the count is in, and names the account, the count and the date', () => {
+ render();
+ act(() => refuseB());
+ expect(warning).not.toHaveBeenCalled();
+ act(() => setRefusedCount(B, 2));
+ expect(warning).toHaveBeenCalledTimes(1);
+ expect(warning.mock.calls[0][0]).toBe('sibling_waiting');
+ act(() => setRefusedCount(B, 2));
+ expect(warning).toHaveBeenCalledTimes(1);
+ });
+
+ it('says the other thing with nothing unsent, or no number', () => {
+ render();
+ act(() => { refuseB(); setRefusedCount(B, null); });
+ expect(warning.mock.calls[0][0]).toBe('sibling_kept');
+ });
+
+ it('isn\'t for someone signed in later', () => {
+ useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never);
+ refuseB();
+ setRefusedCount(B, 2);
+ render();
+ expect(warning).not.toHaveBeenCalled();
+ });
+
+ it('shows at the next page for the one it was meant for, if it got no chance before', () => {
+ refuseB();
+ setRefusedCount(B, 2);
+ render();
+ expect(warning).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx
index d58d07a1d..80666f60c 100644
--- a/components/offline/account-storage.tsx
+++ b/components/offline/account-storage.tsx
@@ -13,7 +13,9 @@ import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offl
import { isOfflineNow, useOfflineStore } from '@/lib/offline/state';
import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress';
import { devicesForAccount } from '@/lib/device-policy';
+import { localExportAllowed } from '@/components/mail-transfer/start-local-export';
import { useAccountStore } from '@/stores/account-store';
+import { openLocalExport } from '@/stores/mail-transfer-store';
import { KeptFiles } from './kept-files';
const MB = 1024 * 1024;
@@ -65,6 +67,7 @@ function useUnsent(account: string) {
export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) {
const t = useTranslations('offline.settings');
const tStorage = useTranslations('offlineStorage');
+ const tExport = useTranslations('offlineExport');
const format = useFormatter();
const enabled = useOfflineStore((s) => s.enabled.includes(account));
const usage = useOfflineStore((s) => s.usage[account]);
@@ -220,6 +223,9 @@ export function AccountStorage({ account, primary, heading }: { account: string;
)}
+
+
+
diff --git a/components/offline/device-wipe-host.tsx b/components/offline/device-wipe-host.tsx
index 3816ca38d..405e94a82 100644
--- a/components/offline/device-wipe-host.tsx
+++ b/components/offline/device-wipe-host.tsx
@@ -2,7 +2,7 @@
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
-import { consumeWipeNote, type WipeNote } from '@/lib/devices/wipe-state';
+import { consumeWipeNote, onRefusedLoginsChanged, takeSiblingToasts, waitDate, type WipeNote } from '@/lib/devices/wipe-state';
import { startDeviceWipe } from '@/lib/offline/device-wipe';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
@@ -33,5 +33,17 @@ export function DeviceWipeHost() {
if (note) toast.warning(wipeNoteText(note, tRef.current), { duration: 0 });
}, [signedIn]);
+ // An account beside this one signed out by itself: once, when it's known what waits.
+ useEffect(() => {
+ const look = () => {
+ for (const wait of takeSiblingToasts(useAuthStore.getState().activeAccountId)) {
+ const values = { name: wait.label, date: waitDate(wait.until) };
+ toast.warning(wait.unsent ? tRef.current('sibling_waiting', { ...values, count: wait.unsent }) : tRef.current('sibling_kept', values), { duration: 0 });
+ }
+ };
+ look();
+ return onRefusedLoginsChanged(look);
+ }, []);
+
return null;
}
diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx
index d3d8f4942..f3940efaa 100644
--- a/components/protocol/protocol-launch-handler-provider.tsx
+++ b/components/protocol/protocol-launch-handler-provider.tsx
@@ -7,6 +7,7 @@ import { usePathname, useRouter } from "@/i18n/navigation";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfig } from "@/hooks/use-config";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
+import { startOsCommands } from "@/lib/desktop/os-commands";
import { flushComposers } from "@/lib/compose-flush-registry";
import { isPopoutPath } from "@/lib/popout/window-role";
import { isStandaloneDisplayMode } from "@/lib/standalone-display";
@@ -110,6 +111,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
const router = useRouter();
const pathname = usePathname();
const tLaunch = useTranslations("pwaLaunch");
+ const tDesktop = useTranslations("desktopOs");
const sentToTarget = useRef(new Set());
const { dialogProps, confirm } = useConfirmDialog();
const [offer, setOffer] = useState(null);
@@ -201,6 +203,22 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
return subscribeHandoff("any", goToTarget);
}, [pathname, leaveFor]);
+ // The desktop app: what the OS asks for arrives over the bridge, not launchQueue.
+ const osEnv = useRef({ leaveFor, push: router.push as (path: string) => void, pathname });
+ osEnv.current = { leaveFor, push: router.push as (path: string) => void, pathname };
+ useEffect(() => {
+ if (isPopoutPath(window.location.pathname)) return;
+ return startOsCommands({
+ leaveFor: (path) => osEnv.current.leaveFor(path),
+ push: (path) => {
+ if (osEnv.current.pathname !== path) osEnv.current.push(path);
+ },
+ notifyFailed: () => toast.error(tLaunch("open_failed")),
+ notifyReplyLost: () => toast.error(tDesktop("reply_lost")),
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
useEffect(() => {
if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return;
diff --git a/components/providers/calendar-alert-provider.tsx b/components/providers/calendar-alert-provider.tsx
index cb0b72b6a..561586746 100644
--- a/components/providers/calendar-alert-provider.tsx
+++ b/components/providers/calendar-alert-provider.tsx
@@ -1,12 +1,15 @@
"use client";
+import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog';
import { useCalendarAlerts } from '@/hooks/use-calendar-alerts';
+import { useStoreReminders } from '@/hooks/use-store-reminders';
import { ToastContainer } from '@/components/ui/toast';
import { LiveRegions } from '@/components/ui/live-regions';
import { useToastStore } from '@/stores/toast-store';
export function CalendarAlertProvider({ children }: { children: React.ReactNode }) {
useCalendarAlerts();
+ useStoreReminders();
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
@@ -15,6 +18,7 @@ export function CalendarAlertProvider({ children }: { children: React.ReactNode
<>
{children}
+
>
);
diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx
index cad5348f1..887ccbf2a 100644
--- a/components/session/__tests__/session-timeout-guard.test.tsx
+++ b/components/session/__tests__/session-timeout-guard.test.tsx
@@ -184,7 +184,7 @@ describe('session timeout guard', () => {
await advance(500);
expect(order).toEqual(['draft saved', 'signed out']);
- expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
});
@@ -196,7 +196,7 @@ describe('session timeout guard', () => {
await advance(DRAFT_FLUSH_TIMEOUT_MS - 1);
expect(logoutAll).not.toHaveBeenCalled();
await advance(1);
- expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
});
it('uses the strictest of the signed-in accounts', async () => {
@@ -258,7 +258,7 @@ describe('session timeout guard', () => {
expect(screen.queryByText('max_title')).toBeNull();
await advance(60 * SECOND);
await advance(0);
- expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 });
});
@@ -276,7 +276,7 @@ describe('session timeout guard', () => {
fireEvent.pointerDown(window);
await advance(0);
- expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
});
@@ -319,7 +319,7 @@ describe('session timeout guard', () => {
// Later in the body than the overlay, so it paints on top at a higher z-index too.
expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await advance(30 * SECOND);
- expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
} finally {
overlay.remove();
hasFocus.mockRestore();
diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx
index 08b0dd983..c208a810a 100644
--- a/components/session/session-timeout-guard.tsx
+++ b/components/session/session-timeout-guard.tsx
@@ -92,7 +92,8 @@ const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: n
* (policy.json `organisation.session`). Activity in any Bulwark tab keeps
* every tab signed in; a warning counts down first; at zero the open and
* minimised composers save their drafts and every account signs out, with
- * the reason on the login page.
+ * the reason on the login page. What the accounts keep on the device hides until
+ * the next sign-in, unless the organisation asks for deletion at once.
*/
export function SessionTimeoutGuard() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -149,7 +150,8 @@ export function SessionTimeoutGuard() {
rememberSessionEnd(endReason(kind, current, false));
const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS);
if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true));
- await useAuthStore.getState().logoutAll({ endProviderSession: false });
+ // Nobody asked: the mail hides until the next sign-in, and unsent messages with it.
+ await useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
}, []);
// The clock, while there is a limit to count.
diff --git a/components/settings/__tests__/desktop-telemetry-settings.test.tsx b/components/settings/__tests__/desktop-telemetry-settings.test.tsx
new file mode 100644
index 000000000..94fabcccb
--- /dev/null
+++ b/components/settings/__tests__/desktop-telemetry-settings.test.tsx
@@ -0,0 +1,189 @@
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from '@/lib/desktop/types';
+import { DesktopTelemetrySettings } from '../desktop-telemetry-settings';
+
+// The desktop app's two switches. A browser sees nothing. The shell decides and asks; this
+// page only shows what it says, and sends a crash file only when the button is pressed.
+
+const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
+vi.mock('@/stores/toast-store', () => ({ toast }));
+vi.mock('next-intl', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useTranslations: () => (key: string) => key,
+ useFormatter: () => ({ dateTime: (date: Date) => date.toISOString() }),
+}));
+vi.mock('../settings-section', async (importOriginal) => ({
+ ...(await importOriginal()),
+ SettingsSection: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+const OFF: DesktopPrivacyState = {
+ telemetry: { on: false, forcedOff: false },
+ crashReports: { on: false, forcedOff: false, uploadUrl: '' },
+};
+const CRASH_ON: DesktopPrivacyState = { ...OFF, crashReports: { on: true, forcedOff: false, uploadUrl: '' } };
+const FILE: DesktopCrashFile = { id: 'a1', createdAt: Date.parse('2026-11-03T10:00:00Z'), bytes: 2048 };
+
+function bridge(state: DesktopPrivacyState, crashes: DesktopCrashFile[] = [], mode: 'standalone' | 'instance' = 'standalone') {
+ const privacy = {
+ get: vi.fn(async () => state),
+ setTelemetry: vi.fn(async (on: boolean) => ({ ...state, telemetry: { ...state.telemetry, on } })),
+ setCrashReports: vi.fn(async (on: boolean) => ({ ...state, crashReports: { ...state.crashReports, on } })),
+ setCrashUploadUrl: vi.fn(async (_url: string) => ({ ok: true, state })),
+ listCrashes: vi.fn(async () => crashes),
+ sendCrash: vi.fn(async (_id: string): Promise => ({ ok: true })),
+ deleteCrashes: vi.fn(async () => {}),
+ onChange: vi.fn(() => () => {}),
+ };
+ (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode, privacy };
+ return privacy;
+}
+
+afterEach(() => {
+ delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
+ toast.success.mockClear();
+ toast.error.mockClear();
+});
+
+const checked = (id: string) => screen.getByTestId(id).getAttribute('aria-checked');
+
+describe('usage info and crash reports', () => {
+ it('shows nothing in a browser', async () => {
+ const { container } = render();
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ expect(container.textContent).toBe('');
+ });
+
+ it('shows nothing under a shell without the bridge member', async () => {
+ (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
+ const { container } = render();
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ expect(container.textContent).toBe('');
+ });
+
+ it('starts with both off, and says what a crash file can hold', async () => {
+ bridge(OFF);
+ render();
+ await screen.findByTestId('desktop-telemetry-toggle');
+ expect(checked('desktop-telemetry-toggle')).toBe('false');
+ expect(checked('desktop-crash-toggle')).toBe('false');
+ expect(screen.getByText('crash_memory')).toBeTruthy();
+ });
+
+ it('asks the shell and shows its answer', async () => {
+ const privacy = bridge(OFF);
+ render();
+ fireEvent.click(await screen.findByTestId('desktop-telemetry-toggle'));
+ await waitFor(() => expect(privacy.setTelemetry).toHaveBeenCalledWith(true));
+ await waitFor(() => expect(checked('desktop-telemetry-toggle')).toBe('true'));
+ expect(privacy.setCrashReports).not.toHaveBeenCalled();
+ });
+
+ it('shows a switch still off when the user said no in the shell\'s dialog', async () => {
+ const privacy = bridge(OFF);
+ privacy.setCrashReports.mockResolvedValue(OFF);
+ render();
+ fireEvent.click(await screen.findByTestId('desktop-crash-toggle'));
+ await waitFor(() => expect(privacy.setCrashReports).toHaveBeenCalledWith(true));
+ expect(checked('desktop-crash-toggle')).toBe('false');
+ });
+
+ it('locks both when a policy forces them off, and says who did', async () => {
+ const privacy = bridge({
+ telemetry: { on: false, forcedOff: true },
+ crashReports: { on: false, forcedOff: true, uploadUrl: '' },
+ });
+ render();
+ const toggle = await screen.findByTestId('desktop-telemetry-toggle');
+ expect(toggle.hasAttribute('disabled')).toBe(true);
+ expect(screen.getByTestId('desktop-crash-toggle').hasAttribute('disabled')).toBe(true);
+ expect(screen.getAllByText('forced_off')).toHaveLength(2);
+ fireEvent.click(toggle);
+ expect(privacy.setTelemetry).not.toHaveBeenCalled();
+ });
+
+ it('adds the note about the instance\'s own admin only on an instance', async () => {
+ bridge(OFF, [], 'instance');
+ render();
+ await screen.findByTestId('desktop-telemetry-toggle');
+ expect(screen.getByText(/instance_note/)).toBeTruthy();
+ });
+});
+
+describe('crash files', () => {
+ it('sends only when the button is pressed', async () => {
+ const privacy = bridge(CRASH_ON, [FILE]);
+ render();
+ const send = await screen.findByTestId('desktop-crash-send');
+ expect(privacy.sendCrash).not.toHaveBeenCalled();
+ fireEvent.click(send);
+ await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalledWith('a1'));
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('sent'));
+ });
+
+ it('says so when no address is set', async () => {
+ const privacy = bridge(CRASH_ON, [FILE]);
+ privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'no-url' });
+ render();
+ fireEvent.click(await screen.findByTestId('desktop-crash-send'));
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('send_no_url'));
+ });
+
+ it('says nothing when the user said no in the dialog', async () => {
+ const privacy = bridge(CRASH_ON, [FILE]);
+ privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'cancelled' });
+ render();
+ fireEvent.click(await screen.findByTestId('desktop-crash-send'));
+ await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalled());
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ expect(toast.error).not.toHaveBeenCalled();
+ expect(toast.success).not.toHaveBeenCalled();
+ });
+
+ it('asks for the address only with crash reports on, and shows a bad one\'s answer', async () => {
+ const privacy = bridge(CRASH_ON);
+ privacy.setCrashUploadUrl.mockResolvedValue({ ok: false, state: CRASH_ON });
+ render();
+ const input = await screen.findByTestId('desktop-crash-url');
+ fireEvent.change(input, { target: { value: 'ftp://nope' } });
+ fireEvent.click(screen.getByRole('button', { name: 'url_save' }));
+ await waitFor(() => expect(privacy.setCrashUploadUrl).toHaveBeenCalledWith('ftp://nope'));
+ await screen.findByText('url_invalid');
+ });
+
+ it('hides the address with crash reports off', async () => {
+ bridge(OFF);
+ render();
+ await screen.findByTestId('desktop-telemetry-toggle');
+ expect(screen.queryByTestId('desktop-crash-url')).toBeNull();
+ });
+
+ it('deletes through the shell', async () => {
+ const privacy = bridge(CRASH_ON, [FILE]);
+ render();
+ fireEvent.click(await screen.findByRole('button', { name: 'delete_all' }));
+ await waitFor(() => expect(privacy.deleteCrashes).toHaveBeenCalledTimes(1));
+ });
+});
+
+describe('the text', () => {
+ const en = JSON.parse(readFileSync(path.join(process.cwd(), 'locales/en/common.json'), 'utf8')).desktopTelemetry as Record;
+
+ it('names everything a heartbeat holds, in plain words', () => {
+ for (const word of [/a random ID/, /app version/, /Node version/, /operating system/, /how long the app has been running/, /how many plugins and themes/]) {
+ expect(en.telemetry_description).toMatch(word);
+ }
+ expect(en.telemetry_description).not.toMatch(/uptime/i);
+ });
+
+ it('says crash reports carry a random ID too', () => {
+ expect(en.crash_description).toMatch(/a random ID/);
+ });
+
+ it('says a crash file can still hold the text of mail after scrubbing', () => {
+ expect(en.crash_memory).toMatch(/can still contain the text of your mail/);
+ });
+});
diff --git a/components/settings/desktop-os-settings.tsx b/components/settings/desktop-os-settings.tsx
new file mode 100644
index 000000000..1703487e6
--- /dev/null
+++ b/components/settings/desktop-os-settings.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { getDesktopBridge } from "@/lib/desktop/bridge";
+import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types";
+import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
+
+/** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */
+const SHOW_SHORTCUT = "CommandOrControl+Shift+B";
+
+function shortcutLabel(platform: string | undefined): string {
+ return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B";
+}
+
+/**
+ * The desktop app's own settings, on the General page: only where there's an app to
+ * set. A browser never sees it, and it renders nothing until the bridge has answered.
+ * Quick compose adds its shortcut here later.
+ */
+export function DesktopOsSettings() {
+ const t = useTranslations("desktopOs");
+ const [status, setStatus] = useState(null);
+ const [defaultApp, setDefaultApp] = useState(null);
+ const bridge = getDesktopBridge();
+ const os = bridge?.os;
+
+ useEffect(() => {
+ if (!os) return;
+ let stale = false;
+ void os.getStatus().then((next) => {
+ if (!stale) setStatus(next);
+ });
+ void os.defaultAppStatus().then((next) => {
+ if (!stale) setDefaultApp(next);
+ });
+ const stop = os.onStatusChange(setStatus);
+ return () => {
+ stale = true;
+ stop();
+ };
+ }, [os]);
+
+ if (!os || !status) return null;
+ const { settings } = status;
+ const change = (patch: Partial) => {
+ void os.setSettings(patch).then(setStatus);
+ };
+
+ return (
+
+
+ change({ closeToTray })} />
+
+
+ change({ startAtLogin })} />
+
+ {settings.startAtLogin && (
+
+ change({ startMinimized })} />
+
+ )}
+
+ change({ attentionOnNewMail })} />
+
+
+ change({ quietHoursEnabled })} />
+
+ {settings.quietHoursEnabled && (
+
+
+ change({ quietHoursStart: event.target.value })}
+ className="rounded-md border border-border bg-background px-2 py-1 text-sm"
+ />
+ {t("quiet_hours_to")}
+ change({ quietHoursEnd: event.target.value })}
+ className="rounded-md border border-border bg-background px-2 py-1 text-sm"
+ />
+
+
+ )}
+
+ change({ showShortcut: on ? SHOW_SHORTCUT : null })}
+ />
+
+
+
+
+
+ );
+}
diff --git a/components/settings/desktop-telemetry-settings.tsx b/components/settings/desktop-telemetry-settings.tsx
new file mode 100644
index 000000000..cc2dda5f3
--- /dev/null
+++ b/components/settings/desktop-telemetry-settings.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useFormatter, useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { getDesktopBridge } from "@/lib/desktop/bridge";
+import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from "@/lib/desktop/types";
+import { toast } from "@/stores/toast-store";
+import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
+
+const SEND_ERROR: Record["reason"], string | null> = {
+ "no-consent": "send_no_consent",
+ "no-url": "send_no_url",
+ "not-found": "send_failed",
+ failed: "send_failed",
+ // The user said no in the dialog: nothing to tell them.
+ cancelled: null,
+};
+
+/**
+ * Usage info and crash reports in the desktop app: each with its own switch, both off
+ * until the user says yes, and only where there's an app. A browser never sees this, and
+ * it renders nothing until the bridge has answered. The shell does the deciding, the
+ * asking and the sending.
+ */
+export function DesktopTelemetrySettings() {
+ const t = useTranslations("desktopTelemetry");
+ const format = useFormatter();
+ const bridge = getDesktopBridge();
+ const privacy = bridge?.privacy;
+ const [state, setState] = useState(null);
+ const [crashes, setCrashes] = useState([]);
+ const [url, setUrl] = useState("");
+ const [urlInvalid, setUrlInvalid] = useState(false);
+ const [sending, setSending] = useState(null);
+
+ const refreshCrashes = useCallback(() => {
+ void privacy?.listCrashes().then(setCrashes, () => {});
+ }, [privacy]);
+
+ useEffect(() => {
+ if (!privacy) return;
+ let stale = false;
+ const apply = (next: DesktopPrivacyState) => {
+ if (stale) return;
+ setState(next);
+ setUrl(next.crashReports.uploadUrl);
+ };
+ void privacy.get().then(apply, () => {});
+ refreshCrashes();
+ const stop = privacy.onChange((next) => {
+ apply(next);
+ refreshCrashes();
+ });
+ return () => {
+ stale = true;
+ stop();
+ };
+ }, [privacy, refreshCrashes]);
+
+ if (!privacy || !state) return null;
+ const { telemetry, crashReports } = state;
+
+ const saveUrl = async () => {
+ const result = await privacy.setCrashUploadUrl(url.trim());
+ setUrlInvalid(!result.ok);
+ if (result.ok) setState(result.state);
+ };
+
+ const send = async (id: string) => {
+ setSending(id);
+ try {
+ const result = await privacy.sendCrash(id);
+ if (result.ok) toast.success(t("sent"));
+ else if (SEND_ERROR[result.reason]) toast.error(t(SEND_ERROR[result.reason]!));
+ } finally {
+ setSending(null);
+ }
+ };
+
+ const showCrashFiles = crashReports.on || crashes.length > 0;
+
+ return (
+
+
+ void privacy.setTelemetry(on).then(setState)}
+ />
+
+
+ void privacy.setCrashReports(on).then((next) => {
+ setState(next);
+ refreshCrashes();
+ })}
+ />
+
+ {!crashReports.forcedOff && (
+
+ {t("crash_memory")}
+ {bridge?.mode === "instance" && ` ${t("instance_note")}`}
+
+ )}
+ {crashReports.on && (
+
+
+ {
+ setUrl(event.target.value);
+ setUrlInvalid(false);
+ }}
+ className="w-64"
+ />
+
+
+
+ )}
+ {showCrashFiles && (
+
+ {crashes.length > 0 && (
+
+ )}
+
+ )}
+ {showCrashFiles && crashes.length === 0 && (
+ {t("files_empty")}
+ )}
+ {crashes.map((crash) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx
index f0bf21780..d9e72d32e 100644
--- a/components/settings/general-settings.tsx
+++ b/components/settings/general-settings.tsx
@@ -6,7 +6,9 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { LanguageSettings } from './language-settings';
import { AppLockSettings } from './app-lock-settings';
import { DesktopKeystoreSettings } from './desktop-keystore-settings';
+import { DesktopTelemetrySettings } from './desktop-telemetry-settings';
import { ProtocolHandlerSettings } from './protocol-handler-settings';
+import { DesktopOsSettings } from './desktop-os-settings';
import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from '@/components/icons';
@@ -29,8 +31,11 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
+
+
{!IS_LITE && }
+
diff --git a/components/ui/__tests__/toast-extra-actions.test.tsx b/components/ui/__tests__/toast-extra-actions.test.tsx
new file mode 100644
index 000000000..10354d4f3
--- /dev/null
+++ b/components/ui/__tests__/toast-extra-actions.test.tsx
@@ -0,0 +1,33 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { ToastItem } from '../toast';
+
+describe('a toast with more than three actions', () => {
+ it('shows them in order, and each closes it', () => {
+ vi.useFakeTimers();
+ const clicked = vi.fn();
+ const onDismiss = vi.fn();
+ const action = (label: string) => ({ label, onClick: () => clicked(label) });
+ render(
+ ,
+ );
+ expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['five', 'ten', 'fifteen', 'join', 'open']);
+ fireEvent.click(screen.getByText('join'));
+ expect(clicked).toHaveBeenCalledWith('join');
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
+});
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
index 28bb16c0a..a3d96a2be 100644
--- a/components/ui/toast.tsx
+++ b/components/ui/toast.tsx
@@ -36,6 +36,8 @@ export interface Toast {
* that has one lays its actions out on a row of their own.
*/
tertiaryAction?: ToastAction;
+ /** More actions at the end of that row. Needs a tertiaryAction. */
+ extraActions?: ToastAction[];
/**
* Work in progress, e.g. an upload: 0..1, or null while the amount is
* unknown. The bottom line then shows progress instead of a countdown,
@@ -291,6 +293,7 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
{actionButton(toast.tertiaryAction)}
{toast.secondaryAction && actionButton(toast.secondaryAction)}
{toast.action && actionButton(toast.action)}
+ {toast.extraActions?.map((action) => {actionButton(action)})}
)}
diff --git a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
index 331953d64..0f3ca9527 100644
--- a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
+++ b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx
@@ -76,6 +76,54 @@ afterEach(() => {
vi.restoreAllMocks();
});
+describe('the 24-hour fetch', () => {
+ it('fires the occurrence of a series the server answered with its master', async () => {
+ const now = Date.now();
+ const occurrenceStart = new Date(now + 5 * 60_000);
+ const stamp = (date: Date) => {
+ const pad = (value: number) => String(value).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`;
+ };
+ // The same weekday and time, years ago: only the expansion finds today's.
+ const first = new Date(occurrenceStart.getTime() - 52 * 7 * 24 * 3_600_000);
+ const master = {
+ id: 'series',
+ uid: 'uid-series',
+ title: 'Weekly',
+ start: stamp(first),
+ duration: 'PT30M',
+ calendarIds: { c1: true },
+ useDefaultAlerts: false,
+ recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }],
+ alerts: {
+ a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT6M', relativeTo: 'start' } },
+ },
+ } as unknown as CalendarEvent;
+ useCalendarStore.setState({ events: [], calendars: [], supportsCalendar: true });
+ useAuthStore.setState({ isAuthenticated: true, client: { queryCalendarEvents: vi.fn(async () => [master]) } as never });
+
+ renderHook(() => useCalendarAlerts());
+
+ await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['Weekly']));
+ });
+});
+
+describe('two tabs', () => {
+ it('rings in one, also when an id changed between the two', async () => {
+ const synthetic = { ...event, id: 'synthetic-2' } as CalendarEvent;
+ renderHook(() => useCalendarAlerts());
+ await waitFor(() => expect(useToastStore.getState().toasts).toHaveLength(1));
+
+ // The other tab has the same reminder under the id the server gives it now.
+ useCalendarStore.setState({ events: [synthetic] });
+ renderHook(() => useCalendarAlerts());
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 700));
+ });
+ expect(useToastStore.getState().toasts).toHaveLength(1);
+ });
+});
+
describe('reminder hooks', () => {
it('reports a fired reminder once, and its dismissal when the user closes it', async () => {
const fired = vi.fn();
diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx
index e53285031..e3757d946 100644
--- a/hooks/__tests__/use-email-drag-routing.test.tsx
+++ b/hooks/__tests__/use-email-drag-routing.test.tsx
@@ -731,4 +731,32 @@ describe('message row drag hover wiring', () => {
expect(retry.payload.get('DownloadURL')).toMatch(/^message\/rfc822:Message.eml:blob:/);
expect(await text(payloadBlob(retry.payload))).toBe(bytes('shared-b', authB));
});
+
+ it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => {
+ const email = mail({ subject: 'Hello world' });
+ const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true);
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never;
+ const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' }));
+ await warm(result);
+ try {
+ startDrag.mockClear();
+ // Plain: still a move to a folder, still DownloadURL for Explorer.
+ expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(true);
+ expect(startDrag).toHaveBeenCalled();
+ expect(nativeDrag).not.toHaveBeenCalled();
+
+ startDrag.mockClear();
+ const preventDefault = vi.fn();
+ const dataTransfer = { setData: vi.fn(), setDragImage: vi.fn(), effectAllowed: '' };
+ act(() => result.current.dragHandlers.onDragStart({ altKey: true, preventDefault, dataTransfer } as unknown as DragEvent));
+ expect(preventDefault).toHaveBeenCalled();
+ expect(startDrag).not.toHaveBeenCalled();
+ await waitFor(() => expect(nativeDrag).toHaveBeenCalled(), { container: document.createElement('div') });
+ const [files] = nativeDrag.mock.calls[0];
+ expect(files[0].name).toBe('Hello world.eml');
+ expect(new TextDecoder().decode(files[0].data)).toBe(bytes('primary-a', authA));
+ } finally {
+ window.bulwarkDesktop = undefined;
+ }
+ });
});
diff --git a/hooks/__tests__/use-new-mail-alerts.test.tsx b/hooks/__tests__/use-new-mail-alerts.test.tsx
index c98b51dca..40c1fcee7 100644
--- a/hooks/__tests__/use-new-mail-alerts.test.tsx
+++ b/hooks/__tests__/use-new-mail-alerts.test.tsx
@@ -39,6 +39,14 @@ describe('new mail alerts', () => {
vi.useRealTimers();
});
+ it('in the desktop app leaves the sound to the notification path, so it plays once', () => {
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: {} } as never;
+ renderHook(() => useNewMailAlerts());
+ arrive('a');
+ expect(playNotificationSound).not.toHaveBeenCalled();
+ window.bulwarkDesktop = undefined;
+ });
+
it('tells screen readers once how many messages came, never what they say', () => {
renderHook(() => useNewMailAlerts());
arrive('a');
diff --git a/hooks/__tests__/use-store-reminders-review.test.tsx b/hooks/__tests__/use-store-reminders-review.test.tsx
new file mode 100644
index 000000000..dfdafce43
--- /dev/null
+++ b/hooks/__tests__/use-store-reminders-review.test.tsx
@@ -0,0 +1,442 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
+import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog';
+import { ToastContainer } from '@/components/ui/toast';
+import { useCalendarAlerts } from '../use-calendar-alerts';
+import { useStoreReminders } from '../use-store-reminders';
+import { claimKey, claimNow } from '@/lib/calendar-reminders/claims';
+import { useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state';
+import { setAppLockCheck } from '@/lib/desktop/app-lock-state';
+import type { BulwarkDesktopBridge, DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types';
+import { clearAllHooks, uiHooks } from '@/lib/plugin-hooks';
+import { useOfflineStore } from '@/lib/offline/state';
+import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
+import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
+import { setStoreBackend } from '@/lib/store/client/api';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useEmailStore } from '@/stores/email-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useToastStore } from '@/stores/toast-store';
+
+const sounds = vi.hoisted(() => ({ play: vi.fn() }));
+vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play }));
+vi.mock('@/lib/title-alert', () => ({ startTitleAlert: vi.fn() }));
+// Real crypto takes real time, and fake time would run ahead of it.
+vi.mock('@/lib/pwa-notify/login-fingerprint', () => ({ loginFingerprint: async () => 'fingerprint' }));
+vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true }));
+const policy = vi.hoisted(() => ({ pushWithoutContent: false }));
+vi.mock('@/lib/device-policy', async (original) => ({
+ ...await original(),
+ devicesForAccount: () => ({ pushWithoutContent: policy.pushWithoutContent }),
+}));
+
+const MIN = 60_000;
+const ACCOUNT = 'acc';
+const START = Date.UTC(2026, 10, 10, 9);
+const FIRE = START - 15 * MIN;
+const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } };
+const FOREIGN_LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://calls.example.org/room' } } };
+
+function event(partial: Record = {}) {
+ return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial };
+}
+
+let store: InProcessStore;
+let server: FakeCalendarJmap;
+let notifyResult: DesktopOsNotifyResult;
+const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult);
+const openExternal = vi.fn();
+
+function desktop() {
+ window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge;
+}
+
+function setVisibility(state: 'visible' | 'hidden') {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state });
+}
+
+function Toasts() {
+ const toasts = useToastStore((s) => s.toasts);
+ const removeToast = useToastStore((s) => s.removeToast);
+ return ;
+}
+
+async function advance(ms: number) {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(ms);
+ });
+}
+
+async function syncedWith(...events: Record[]) {
+ for (const partial of events) server.addEvent(partial);
+ await store.sync(ACCOUNT);
+}
+
+const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title);
+
+/** The login fingerprint is real crypto, in real time. Fake time must not run ahead of it. */
+async function untilToast() {
+ for (let tries = 0; tries < 500 && toastTitles().length === 0; tries += 1) {
+ await act(async () => {
+ await new Promise((resolve) => setImmediate(resolve));
+ });
+ }
+}
+
+/** Two alerts due with the app closed. Leaves the hook unmounted and the clock moved on. */
+async function missedTwo(...extra: Record[]) {
+ await syncedWith(event(extra[0] ?? {}), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ const before = renderHook(() => useStoreReminders());
+ await advance(0);
+ before.unmount();
+ vi.setSystemTime(FIRE + 3 * 60 * MIN);
+}
+
+beforeEach(() => {
+ vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
+ vi.clearAllMocks();
+ clearAllHooks();
+ localStorage.clear();
+ setAppLockCheck(null);
+ setVisibility('visible');
+ policy.pushWithoutContent = false;
+ notifyResult = { shown: true, playSound: false };
+ useToastStore.setState({ toasts: [] });
+ useReminderSource.setState({ driven: {} });
+ useMissedReminders.getState().close();
+ useCalendarNotificationStore.getState().clearAll();
+ useOfflineStore.setState({ enabled: [ACCOUNT] });
+ useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT });
+ useCalendarStore.setState({ events: [], calendars: [] });
+ usePolicyStore.setState({ loaded: true, policy: { ...usePolicyStore.getState().policy, features: { ...usePolicyStore.getState().policy.features, calendarEnabled: true } } });
+ useAccountStore.setState({ accounts: [{ id: ACCOUNT, cookieSlot: 1, serverUrl: 'https://one.example', username: 'a@example.org' }] as never });
+ useEmailStore.setState({ isPushConnected: true });
+ useSettingsStore.setState({
+ calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true,
+ doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin',
+ });
+ server = new FakeCalendarJmap('1.0');
+ store = new InProcessStore(server, CALENDAR_SESSION);
+ setStoreBackend(store);
+});
+
+afterEach(() => {
+ setStoreBackend(null);
+ window.bulwarkDesktop = undefined;
+ setAppLockCheck(null);
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ Reflect.deleteProperty(navigator, 'serviceWorker');
+});
+
+function grantNotifications() {
+ const showNotification = vi.fn(async (_title: string, _options: NotificationOptions) => {});
+ vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' }));
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) },
+ });
+ return showNotification;
+}
+
+describe('Join', () => {
+ beforeEach(desktop);
+
+ it('says the host of a link to a service nobody knows', async () => {
+ await syncedWith(event(FOREIGN_LINK));
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ const join = notify.mock.calls[0][0].actions!.find((action) => action.id === 'join')!;
+ expect(join.label).toBe('join_host');
+ });
+
+ it('asks the link check, and a stopped link opens nothing and leaves the reminder standing', async () => {
+ await syncedWith(event(FOREIGN_LINK));
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ uiHooks.onBeforeExternalLink.register('t', () => false);
+ render();
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ await untilToast();
+ const dismiss = vi.spyOn(store, 'area');
+ fireEvent.click(screen.getByText('join_host'));
+ await advance(100);
+ expect(openExternal).not.toHaveBeenCalled();
+ expect(dismiss.mock.calls.filter(([, name]) => name === 'reminders.dismiss')).toEqual([]);
+ });
+
+ it('goes out through the app when the link check lets it', async () => {
+ await syncedWith(event(FOREIGN_LINK));
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ render();
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ await untilToast();
+ const area = vi.spyOn(store, 'area');
+ fireEvent.click(screen.getByText('join_host'));
+ await advance(100);
+ expect(openExternal).toHaveBeenCalledWith('https://calls.example.org/room');
+ expect(area.mock.calls.filter(([, name]) => name === 'reminders.dismiss')).toHaveLength(1);
+ });
+
+ it('names each Join in the summary for a screen reader', async () => {
+ await missedTwo(FOREIGN_LINK);
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ render(<>>);
+ renderHook(() => useStoreReminders());
+ await advance(20_000);
+ await untilToast();
+ fireEvent.click(screen.getByText('missed_show'));
+ await advance(0);
+ expect(screen.getByText('join_host').getAttribute('aria-label')).toBe('join_named');
+ });
+});
+
+describe('with pushWithoutContent', () => {
+ it('keeps the title off a browser system notification, on the store path', async () => {
+ policy.pushWithoutContent = true;
+ const shown = grantNotifications();
+ setVisibility('hidden');
+ await syncedWith(event());
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + 3000);
+ expect(shown).toHaveBeenCalledTimes(1);
+ expect(shown.mock.calls[0][0]).toBe('generic_title');
+ expect(shown.mock.calls[0][1].body).toBe('');
+ expect(toastTitles()).toEqual(['Standup']);
+ });
+
+ it('keeps the titles off the summary as well', async () => {
+ policy.pushWithoutContent = true;
+ await missedTwo();
+ const shown = grantNotifications();
+ setVisibility('hidden');
+ renderHook(() => useStoreReminders());
+ await advance(20_000 + 3000);
+ expect(shown).toHaveBeenCalledTimes(1);
+ expect(JSON.stringify(shown.mock.calls[0])).not.toContain('Standup');
+ });
+
+ it('keeps it off on the path without a store', async () => {
+ policy.pushWithoutContent = true;
+ useOfflineStore.setState({ enabled: [] });
+ const shown = grantNotifications();
+ setVisibility('hidden');
+ useCalendarStore.setState({
+ events: [{ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never,
+ });
+ renderHook(() => useCalendarAlerts());
+ await advance(FIRE - Date.now() + 3000);
+ expect(shown).toHaveBeenCalledTimes(1);
+ expect(shown.mock.calls[0][0]).toBe('alert_title');
+ expect(shown.mock.calls[0][1].body).toBe('');
+ });
+});
+
+describe('when the OS refuses or fails', () => {
+ beforeEach(desktop);
+
+ it('a rejected notify keeps a reminder for the summary', async () => {
+ await syncedWith(event());
+ notify.mockRejectedValueOnce(new Error('main is gone'));
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ await advance(5 * MIN);
+ expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/);
+ });
+
+ it('a failed toast keeps a reminder for the summary', async () => {
+ await syncedWith(event());
+ notifyResult = { shown: false, reason: 'failed', playSound: false };
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ notifyResult = { shown: true, playSound: false };
+ await advance(5 * MIN);
+ expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/);
+ expect(notify.mock.calls.at(-1)![0].body).toBe('Standup');
+ });
+
+ it('a rejected notify puts a summary straight back', async () => {
+ await missedTwo();
+ notify.mockRejectedValueOnce(new Error('main is gone'));
+ renderHook(() => useStoreReminders());
+ await advance(20_000);
+ expect(notify).toHaveBeenCalledTimes(1);
+ await advance(5 * MIN);
+ expect(notify).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('a summary in the app', () => {
+ beforeEach(desktop);
+
+ it('comes back after a reload that lost the toast', async () => {
+ await missedTwo();
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ const page = renderHook(() => useStoreReminders());
+ await advance(20_000);
+ await untilToast();
+ expect(toastTitles()).toEqual(['missed_title']);
+
+ // A reload: the toast and the page are gone, nobody dismissed anything.
+ page.unmount();
+ useToastStore.setState({ toasts: [] });
+ renderHook(() => useStoreReminders());
+ await advance(10 * MIN);
+ expect(toastTitles()).toEqual(['missed_title']);
+ });
+
+ it('stays one toast for as long as it is on screen', async () => {
+ await missedTwo();
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ renderHook(() => useStoreReminders());
+ await advance(20_000);
+ await untilToast();
+ await advance(30 * MIN);
+ expect(toastTitles()).toEqual(['missed_title']);
+ });
+
+ it('does not come back after it was closed', async () => {
+ await missedTwo();
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ render();
+ const page = renderHook(() => useStoreReminders());
+ await advance(20_000);
+ await untilToast();
+ act(() => useToastStore.getState().toasts[0].onDismiss!());
+ useToastStore.setState({ toasts: [] });
+ await advance(30 * MIN);
+ page.unmount();
+ renderHook(() => useStoreReminders());
+ await advance(30 * MIN);
+ expect(toastTitles()).toEqual([]);
+ });
+
+ it('leaves out what the path without a store already rang', async () => {
+ await missedTwo();
+ claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE);
+ renderHook(() => useStoreReminders());
+ await advance(20_000);
+ expect(notify.mock.calls[0][0].body).toBe('Review');
+ });
+
+ it('says nothing at all when that was all of it', async () => {
+ await missedTwo();
+ claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE);
+ claimNow(claimKey(ACCOUNT, 'uid-2', 'a', FIRE), FIRE);
+ renderHook(() => useStoreReminders());
+ await advance(20_000 + 10 * MIN);
+ expect(notify).not.toHaveBeenCalled();
+ expect(toastTitles()).toEqual([]);
+ });
+});
+
+describe('the organisation has the calendar off', () => {
+ beforeEach(() => {
+ usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, features: { ...usePolicyStore.getState().policy.features, calendarEnabled: false } } });
+ });
+
+ it('the store path stays quiet, and nothing counts as missed later', async () => {
+ desktop();
+ await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + MIN);
+ expect(notify).not.toHaveBeenCalled();
+ await advance(20 * MIN);
+ expect(notify).not.toHaveBeenCalled();
+ expect(toastTitles()).toEqual([]);
+ });
+
+ it('the path without a store stays quiet', async () => {
+ useOfflineStore.setState({ enabled: [] });
+ useCalendarStore.setState({
+ events: [{ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never,
+ });
+ renderHook(() => useCalendarAlerts());
+ await advance(FIRE - Date.now() + MIN);
+ expect(toastTitles()).toEqual([]);
+ });
+});
+
+describe('the path without a store on the desktop', () => {
+ const plain = () => useCalendarStore.setState({
+ events: [{ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never,
+ });
+
+ beforeEach(() => {
+ desktop();
+ useOfflineStore.setState({ enabled: [] });
+ plain();
+ });
+
+ it('stands down while locked: nothing fires, nothing is used up', async () => {
+ let locked = true;
+ setAppLockCheck(() => locked);
+ renderHook(() => useCalendarAlerts());
+ await advance(FIRE - Date.now() + MIN);
+ expect(notify).not.toHaveBeenCalled();
+ expect(toastTitles()).toEqual([]);
+
+ locked = false;
+ await advance(MIN);
+ expect(toastTitles()).toEqual(['Standup']);
+ });
+
+ it('sends an id that names nothing when the title is hidden', async () => {
+ policy.pushWithoutContent = true;
+ renderHook(() => useCalendarAlerts());
+ await advance(FIRE - Date.now());
+ expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('e1');
+ expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup');
+ });
+
+ it('plays no sound in quiet hours or a pause', async () => {
+ notifyResult = { shown: false, reason: 'quiet-hours', playSound: false };
+ renderHook(() => useCalendarAlerts());
+ await advance(FIRE - Date.now());
+ expect(notify.mock.calls[0][0].sound).toBe(true);
+ expect(sounds.play).not.toHaveBeenCalled();
+ });
+
+ it('plays the sound main asks for', async () => {
+ notifyResult = { shown: true, playSound: true };
+ renderHook(() => useCalendarAlerts());
+ await advance(FIRE - Date.now());
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('the click on a browser notification', () => {
+ it('carries the address in the user language', async () => {
+ const shown = grantNotifications();
+ setVisibility('hidden');
+ await syncedWith(event());
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + 3000);
+ expect((shown.mock.calls[0][1].data as { url: string }).url).toMatch(/\/calendar$/);
+ });
+});
+
+describe('without a push channel', () => {
+ it('syncs every few minutes, so an event made elsewhere still rings', async () => {
+ useEmailStore.setState({ isPushConnected: false });
+ await syncedWith();
+ const sync = vi.spyOn(store, 'sync');
+ renderHook(() => useStoreReminders());
+ await advance(11 * MIN);
+ expect(sync.mock.calls.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('leaves it to the push channel when there is one', async () => {
+ useEmailStore.setState({ isPushConnected: true });
+ await syncedWith();
+ const sync = vi.spyOn(store, 'sync');
+ renderHook(() => useStoreReminders());
+ await advance(11 * MIN);
+ expect(sync).not.toHaveBeenCalled();
+ });
+});
diff --git a/hooks/__tests__/use-store-reminders.test.tsx b/hooks/__tests__/use-store-reminders.test.tsx
new file mode 100644
index 000000000..229b8ab80
--- /dev/null
+++ b/hooks/__tests__/use-store-reminders.test.tsx
@@ -0,0 +1,383 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
+import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog';
+import { ToastContainer } from '@/components/ui/toast';
+import { useCalendarAlerts } from '../use-calendar-alerts';
+import { useStoreReminders } from '../use-store-reminders';
+import { useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state';
+import { setAppLockCheck } from '@/lib/desktop/app-lock-state';
+import type { BulwarkDesktopBridge, DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types';
+import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks';
+import { useOfflineStore } from '@/lib/offline/state';
+import { emitStoreEvent } from '@/lib/offline/store-events';
+import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
+import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
+import { setStoreBackend } from '@/lib/store/client/api';
+import { useAuthStore } from '@/stores/auth-store';
+import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useToastStore } from '@/stores/toast-store';
+
+const sounds = vi.hoisted(() => ({ play: vi.fn() }));
+vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play }));
+vi.mock('@/lib/title-alert', () => ({ startTitleAlert: vi.fn() }));
+// Real crypto takes real time, and fake time would run ahead of it.
+vi.mock('@/lib/pwa-notify/login-fingerprint', () => ({ loginFingerprint: async () => 'fingerprint' }));
+vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true }));
+const popout = vi.hoisted(() => ({ value: false }));
+vi.mock('@/lib/popout/window-role', async (original) => ({
+ ...await original(),
+ isPopoutWindow: () => popout.value,
+}));
+
+const MIN = 60_000;
+const ACCOUNT = 'acc';
+const START = Date.UTC(2026, 10, 10, 9);
+const FIRE = START - 15 * MIN;
+const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } };
+const LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } };
+
+function event(partial: Record = {}) {
+ return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial };
+}
+
+let store: InProcessStore;
+let server: FakeCalendarJmap;
+let notifyResult: DesktopOsNotifyResult;
+const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult);
+const openExternal = vi.fn();
+
+function desktop() {
+ window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge;
+}
+
+function setVisibility(state: 'visible' | 'hidden') {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state });
+}
+
+function Toasts() {
+ const toasts = useToastStore((s) => s.toasts);
+ const removeToast = useToastStore((s) => s.removeToast);
+ return ;
+}
+
+async function advance(ms: number) {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(ms);
+ });
+}
+
+async function syncedWith(...events: Record[]) {
+ for (const partial of events) server.addEvent(partial);
+ await store.sync(ACCOUNT);
+}
+
+const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title);
+
+beforeEach(() => {
+ vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
+ vi.clearAllMocks();
+ popout.value = false;
+ clearAllHooks();
+ localStorage.clear();
+ setAppLockCheck(null);
+ setVisibility('visible');
+ notifyResult = { shown: true, playSound: false };
+ useToastStore.setState({ toasts: [] });
+ useReminderSource.setState({ driven: {} });
+ useMissedReminders.getState().close();
+ useCalendarNotificationStore.getState().clearAll();
+ useOfflineStore.setState({ enabled: [ACCOUNT] });
+ useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT });
+ useCalendarStore.setState({ events: [], calendars: [] });
+ useSettingsStore.setState({
+ calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true,
+ doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin',
+ });
+ server = new FakeCalendarJmap('1.0');
+ store = new InProcessStore(server, CALENDAR_SESSION);
+ setStoreBackend(store);
+});
+
+afterEach(() => {
+ setStoreBackend(null);
+ window.bulwarkDesktop = undefined;
+ setAppLockCheck(null);
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+describe('in the desktop app', () => {
+ beforeEach(desktop);
+
+ it('fires a native notification with the window hidden, at the time, with the network gone', async () => {
+ await syncedWith(event(LINK));
+ await store.setOnline(false);
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() - 1000);
+ expect(notify).not.toHaveBeenCalled();
+ await advance(1000);
+ expect(notify).toHaveBeenCalledTimes(1);
+ const sent = notify.mock.calls[0][0];
+ expect(sent.title).toBe('Standup');
+ expect(sent.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']);
+ expect(toastTitles()).toEqual([]);
+ });
+
+ it('shows no Join for an event without a link', async () => {
+ await syncedWith(event());
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(notify.mock.calls[0][0].actions!.map((action) => action.id)).not.toContain('join');
+ });
+
+ it('plays the sound main asks for, and asks for none in Do not disturb', async () => {
+ await syncedWith(event());
+ notifyResult = { shown: true, playSound: true };
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(notify.mock.calls[0][0].sound).toBe(true);
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+
+ useSettingsStore.setState({ doNotDisturb: true });
+ await syncedWith(event({ id: 'e2', uid: 'uid-2', start: '2026-11-10T10:01:00' }));
+ emitStoreEvent({ type: 'changed', account: ACCOUNT, collections: ['CalendarEvent'] });
+ await advance(MIN);
+ expect(notify.mock.calls[1][0].sound).toBe(false);
+ });
+
+ it('shows it in the app when the window is in front, with the same choices', async () => {
+ await syncedWith(event(LINK));
+ notifyResult = { shown: false, reason: 'focused', playSound: true };
+ render();
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(toastTitles()).toEqual(['Standup']);
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+ expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['snooze', 'snooze', 'snooze', 'join', 'open']);
+ fireEvent.click(screen.getByText('join'));
+ await advance(0);
+ expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup');
+ });
+
+ it('snoozing from the in-app toast brings it back, and closing it does not', async () => {
+ await syncedWith(event());
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ render();
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ fireEvent.click(screen.getAllByText('snooze')[0]);
+ await advance(5 * MIN);
+ expect(notify).toHaveBeenCalledTimes(2);
+
+ await advance(1000);
+ const again = useToastStore.getState().toasts.at(-1)!;
+ act(() => again.onDismiss!());
+ await advance(60 * MIN);
+ expect(notify).toHaveBeenCalledTimes(2);
+ });
+
+ it('holds what fires while paused or in quiet hours for the summary afterwards', async () => {
+ await syncedWith(event());
+ notifyResult = { shown: false, reason: 'quiet-hours', playSound: false };
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(toastTitles()).toEqual([]);
+
+ // Still quiet: the summary is tried and put back.
+ await advance(5 * MIN);
+ const tried = notify.mock.calls.length;
+ expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/);
+
+ notifyResult = { shown: true, playSound: false };
+ await advance(5 * MIN);
+ expect(notify.mock.calls.length).toBe(tried + 1);
+ expect(notify.mock.calls.at(-1)![0].body).toBe('Standup');
+
+ await advance(30 * MIN);
+ expect(notify.mock.calls.length).toBe(tried + 1);
+ });
+
+ it('says nothing of the event while locked, and tells afterwards', async () => {
+ await syncedWith(event());
+ let locked = true;
+ setAppLockCheck(() => locked);
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup');
+ expect(notify.mock.calls[0][0].actions).toBeUndefined();
+ expect(toastTitles()).toEqual([]);
+
+ await advance(10 * MIN);
+ expect(notify).toHaveBeenCalledTimes(1);
+
+ locked = false;
+ await advance(5 * MIN);
+ expect(notify).toHaveBeenCalledTimes(2);
+ expect(notify.mock.calls[1][0].body).toBe('Standup');
+ });
+
+ it('summarises what was missed once, with a native notification, across a restart', async () => {
+ await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ const before = renderHook(() => useStoreReminders());
+ await advance(0);
+ before.unmount();
+ vi.setSystemTime(FIRE + 3 * 60 * MIN);
+
+ const after = renderHook(() => useStoreReminders());
+ await advance(20_000);
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(notify.mock.calls[0][0]).toMatchObject({ title: 'missed_title', body: 'Standup, Review' });
+ after.unmount();
+
+ renderHook(() => useStoreReminders());
+ await advance(60 * MIN);
+ expect(notify).toHaveBeenCalledTimes(1);
+ });
+
+ it('summarises in the app when the window is in front, and the dialog lists them', async () => {
+ await syncedWith(event(LINK), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ notifyResult = { shown: false, reason: 'focused', playSound: false };
+ const before = renderHook(() => useStoreReminders());
+ await advance(0);
+ before.unmount();
+ vi.setSystemTime(FIRE + 3 * 60 * MIN);
+ render(<>>);
+ renderHook(() => useStoreReminders());
+ await advance(20_000);
+ expect(toastTitles()).toEqual(['missed_title']);
+ fireEvent.click(screen.getByText('missed_show'));
+ await advance(0);
+ const list = screen.getByTestId('missed-reminders');
+ expect(list.textContent).toContain('Standup');
+ expect(list.textContent).toContain('Review');
+ expect(screen.getAllByText('join')).toHaveLength(1);
+ fireEvent.click(screen.getByText('done'));
+ await advance(0);
+ expect(screen.queryByTestId('missed-reminders')).toBeNull();
+ });
+
+ it('tells plugins, and lets one take the reminder over', async () => {
+ await syncedWith(event());
+ const fired = vi.fn();
+ calendarHooks.onCalendarAlert.register('t', fired);
+ calendarHooks.onBeforeCalendarAlert.register('t', () => false);
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(fired).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', eventId: 'e1', eventTitle: 'Standup' }));
+ expect(notify).not.toHaveBeenCalled();
+ });
+
+ function calendarStoreHas(uid: string, title: string) {
+ useCalendarStore.setState({
+ events: [{ id: 'e1', uid, title, start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never,
+ });
+ }
+
+ it('makes the old path stand down once the store has this account', async () => {
+ await syncedWith(event());
+ // Not the same event as far as the claim can tell: only standing down keeps it quiet.
+ calendarStoreHas('another-uid', 'Old path');
+ renderHook(() => {
+ useCalendarAlerts();
+ useStoreReminders();
+ });
+ await advance(FIRE - Date.now() + 2 * MIN);
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(toastTitles()).toEqual([]);
+ });
+
+ it('lets the two paths tell each other what fired, for the moment the store takes over', async () => {
+ await syncedWith(event());
+ calendarStoreHas('uid-1', 'Standup');
+ const { claimKey, claimNow } = await import('@/lib/calendar-reminders/claims');
+ // The old path got there just before the store was ready.
+ expect(claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE)).toBe(true);
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + 2 * MIN);
+ expect(notify).not.toHaveBeenCalled();
+ });
+});
+
+describe('in a browser', () => {
+ function grantNotifications(showNotification = vi.fn(async () => {})) {
+ vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' }));
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) },
+ });
+ return showNotification;
+ }
+
+ afterEach(() => {
+ Reflect.deleteProperty(navigator, 'serviceWorker');
+ });
+
+ it('keeps the in-app toast, without desktop buttons', async () => {
+ await syncedWith(event(LINK));
+ render();
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ const toast = useToastStore.getState().toasts[0];
+ expect(toast.title).toBe('Standup');
+ expect(toast.action).toBeUndefined();
+ expect(toast.extraActions).toBeUndefined();
+ expect(sounds.play).toHaveBeenCalledTimes(1);
+ });
+
+ it('adds a system notification only where permission is granted and the tab is out of view', async () => {
+ await syncedWith(event());
+ const shown = grantNotifications();
+ setVisibility('hidden');
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + 3000);
+ expect(toastTitles()).toEqual(['Standup']);
+ expect(shown).toHaveBeenCalledTimes(1);
+ expect(shown).toHaveBeenCalledWith('Standup', expect.objectContaining({ data: expect.objectContaining({ kind: 'calendar-reminder' }), silent: false }));
+ });
+
+ it('asks for no permission and shows none when it was not granted', async () => {
+ await syncedWith(event());
+ const request = vi.fn();
+ vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'default', requestPermission: request }));
+ setVisibility('hidden');
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + 3000);
+ expect(toastTitles()).toEqual(['Standup']);
+ expect(request).not.toHaveBeenCalled();
+ });
+
+ it('fires in one of two tabs', async () => {
+ await syncedWith(event());
+ renderHook(() => useStoreReminders());
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + MIN);
+ expect(toastTitles()).toEqual(['Standup']);
+ });
+
+ it('closes the toast in this window when another acknowledges the reminder', async () => {
+ await syncedWith(event());
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now());
+ expect(toastTitles()).toEqual(['Standup']);
+ const key = `${Object.keys(CALENDAR_SESSION.accounts)[0]}:uid-1:a:${FIRE}`;
+ const other = new BroadcastChannel('bulwark-calendar-reminders');
+ other.postMessage({ kind: 'acknowledged', keys: [key] });
+ other.close();
+ vi.useRealTimers();
+ await vi.waitFor(() => expect(toastTitles()).toEqual([]));
+ });
+
+ it('gives nothing to a pop-out', async () => {
+ popout.value = true;
+ await syncedWith(event());
+ const area = vi.spyOn(store, 'area');
+ renderHook(() => useStoreReminders());
+ await advance(FIRE - Date.now() + MIN);
+ expect(area).not.toHaveBeenCalled();
+ expect(toastTitles()).toEqual([]);
+ });
+});
diff --git a/hooks/use-app-badge.ts b/hooks/use-app-badge.ts
index 927a869b5..d8a33b320 100644
--- a/hooks/use-app-badge.ts
+++ b/hooks/use-app-badge.ts
@@ -2,6 +2,8 @@
import { useEffect } from 'react';
import { debug } from '@/lib/debug';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { isPopoutWindow } from '@/lib/popout/window-role';
/**
* Badges the *installed* PWA's icon — the shortcut Chrome/Edge pin next to
@@ -35,6 +37,13 @@ export function useAppBadge(count: number, enabled = true): void {
const effectiveCount = enabled ? count : 0;
+ // The desktop app's own icon. A pop-out has its own account and no say in it.
+ const desktopOs = getDesktopBridge()?.os;
+ if (desktopOs) {
+ if (!isPopoutWindow()) desktopOs.setBadge(effectiveCount);
+ return;
+ }
+
void (async () => {
try {
if (effectiveCount > 0) {
diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts
index 83cdce627..6136c4453 100644
--- a/hooks/use-attachment-drag.ts
+++ b/hooks/use-attachment-drag.ts
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, DragEvent } from "react";
+import { canDragNativeFiles, copyFilesToClipboard, startNativeFileDrag } from "@/lib/desktop/file-drag";
// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on
// drop to materialize a real file. Firefox and Safari ignore it, so we only
@@ -33,6 +34,8 @@ export interface AttachmentDragSource {
name: string;
type: string;
getBlobUrl: () => Promise;
+ /** The bytes themselves, for the desktop app's native drag. */
+ getBlob?: () => Promise;
}
export interface UseAttachmentDragResult {
@@ -40,6 +43,8 @@ export interface UseAttachmentDragResult {
onPointerEnter: () => void;
onDragStart: (e: DragEvent) => void;
onDragEnd: (e: DragEvent) => void;
+ /** The desktop app: put the file on the clipboard, for pasting into Explorer or Finder. */
+ onCopyAsFile?: () => Promise;
}
const NOOP_HANDLERS: UseAttachmentDragResult = {
@@ -56,6 +61,8 @@ export function useAttachmentDrag(
const urlRef = useRef(null);
const ownedRef = useRef(false);
const inFlightRef = useRef | null>(null);
+ const blobRef = useRef(null);
+ const nativeDrag = canDragNativeFiles() && !!source.getBlob;
useEffect(() => {
return () => {
@@ -70,6 +77,19 @@ export function useAttachmentDrag(
const prefetch = useCallback(() => {
if (!enabled) return;
+ if (nativeDrag) {
+ if (blobRef.current || inFlightRef.current) return;
+ inFlightRef.current = source.getBlob!()
+ .then((blob) => {
+ blobRef.current = blob;
+ return null;
+ })
+ .catch(() => null)
+ .finally(() => {
+ inFlightRef.current = null;
+ });
+ return;
+ }
if (urlRef.current || inFlightRef.current) return;
inFlightRef.current = source
.getBlobUrl()
@@ -87,7 +107,7 @@ export function useAttachmentDrag(
.finally(() => {
inFlightRef.current = null;
});
- }, [enabled, source]);
+ }, [enabled, source, nativeDrag]);
const handleDragStart = useCallback(
(e: DragEvent) => {
@@ -95,6 +115,15 @@ export function useAttachmentDrag(
const name = source.name || "download";
const type = source.type || "application/octet-stream";
+ // In the desktop app the OS's own drag, with the real file on disk first. The bytes
+ // aren't here yet: fetch them for the next try, as below.
+ if (nativeDrag) {
+ e.preventDefault();
+ if (blobRef.current) void startNativeFileDrag([{ name, blob: blobRef.current }]);
+ else prefetch();
+ return;
+ }
+
if (!url) {
// Blob isn't materialized yet. Kick off the fetch so the next attempt
// works, but cancel this drag so the user doesn't get a silent failure
@@ -111,7 +140,7 @@ export function useAttachmentDrag(
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
e.dataTransfer.effectAllowed = "copyMove";
},
- [source.name, source.type, prefetch],
+ [source.name, source.type, prefetch, nativeDrag],
);
const handleDragEnd = useCallback(() => {
@@ -125,6 +154,11 @@ export function useAttachmentDrag(
}
}, []);
+ const copyAsFile = useCallback(async () => {
+ const blob = blobRef.current ?? await source.getBlob?.();
+ return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false;
+ }, [source]);
+
if (!enabled) return NOOP_HANDLERS;
return {
@@ -132,5 +166,6 @@ export function useAttachmentDrag(
onPointerEnter: prefetch,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
+ ...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}),
};
}
diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts
index 87fbd0a66..b4757dc59 100644
--- a/hooks/use-calendar-alerts.ts
+++ b/hooks/use-calendar-alerts.ts
@@ -2,20 +2,29 @@
import { useEffect, useRef, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
-import { parseISO } from 'date-fns';
+import { format, parseISO } from 'date-fns';
import { isPopoutWindow } from '@/lib/popout/window-role';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
+import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useTaskStore } from '@/stores/task-store';
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
import { useToastStore } from '@/stores/toast-store';
import { getPendingAlerts, getPendingTaskAlerts, getNextAlertFireTime, buildAlertKey } from '@/lib/calendar-alerts';
+import { claimAlert, claimKey } from '@/lib/calendar-reminders/claims';
+import { useReminderSource } from '@/lib/calendar-reminders/state';
+import { showSystemReminder } from '@/lib/calendar-reminders/system-notification';
+import { notifyWithoutStore } from '@/lib/desktop/reminder-notifications';
+import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { playNotificationSound } from '@/lib/notification-sound';
import { lastingToastDuration } from '@/lib/toast-duration';
import { startTitleAlert } from '@/lib/title-alert';
import { getPathPrefix } from '@/lib/browser-navigation';
import { goInApp } from '@/lib/plugin-sandbox/page-navigation';
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { contentHiddenFor } from '@/lib/desktop/mail-notifications';
import type { CalendarEvent } from '@/lib/jmap/types';
import type { CalendarAlertView } from '@/lib/plugin-types';
import { calendarHooks } from '@/lib/plugin-hooks';
@@ -24,6 +33,8 @@ import { changesAnyType, subscribePushStateChanges } from '@/lib/push-events';
const CHECK_INTERVAL_MS = 60 * 1000;
const PROACTIVE_FETCH_HOURS = 24;
const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5;
+// Wall clocks: an occurrence's can be a day off the instant it's at.
+const EXPAND_MARGIN_MS = 27 * 60 * 60 * 1000;
// Longest single wait for the next reminder; the periodic check covers the rest.
const MAX_WAKE_DELAY_MS = 6 * 60 * 60 * 1000;
/** How long a reminder stays at the shortest "Notification duration". */
@@ -43,7 +54,7 @@ export function reminderToastDuration(): number {
* Whether a reminder plays its sound. Do not disturb keeps reminders on
* screen (they were asked for) but silent.
*/
-function reminderSoundOn(soundSetting: boolean): boolean {
+export function reminderSoundOn(soundSetting: boolean): boolean {
return soundSetting && !useSettingsStore.getState().doNotDisturb;
}
@@ -52,7 +63,7 @@ function reminderSoundOn(soundSetting: boolean): boolean {
* is in the background. Reminders were asked for, so Do not disturb keeps
* this too.
*/
-function reminderTitleAlert(text: string): void {
+export function reminderTitleAlert(text: string): void {
if (useSettingsStore.getState().visualAlerts) startTitleAlert(text);
}
@@ -62,7 +73,7 @@ function reminderTitleAlert(text: string): void {
* itself does. onCalendarAlert fires either way. With no plugin asked, the
* reminder is shown at once.
*/
-function remind(view: CalendarAlertView, show: () => void): void {
+export function remind(view: CalendarAlertView, show: () => void): void {
void calendarHooks.onCalendarAlert.emit(view);
if (calendarHooks.onBeforeCalendarAlert.size === 0) {
show();
@@ -74,11 +85,14 @@ function remind(view: CalendarAlertView, show: () => void): void {
}
export function useCalendarAlerts() {
- const { isAuthenticated: signedIn, client } = useAuthStore();
+ const { isAuthenticated: signedIn, client, activeAccountId } = useAuthStore();
+ // Reminders of an account with a local store come from there: useStoreReminders.
+ const fromStore = useReminderSource((s) => !!activeAccountId && !!s.driven[activeAccountId]);
+ const calendarAllowed = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
// The main window rings; a pop-out would ring the same alert again.
- const isAuthenticated = signedIn && !isPopoutWindow();
+ const isAuthenticated = signedIn && !isPopoutWindow() && !fromStore && calendarAllowed;
const { events, calendars, supportsCalendar } = useCalendarStore();
- const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore();
+ const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks } = useSettingsStore();
const { tasks: storeTasks } = useTaskStore();
const { acknowledgedAlerts, acknowledgeAlert, cleanupStaleAlerts } = useCalendarNotificationStore();
const addToast = useToastStore((s) => s.addToast);
@@ -92,12 +106,35 @@ export function useCalendarAlerts() {
const wakeTimerRef = useRef | null>(null);
const checkAlertsRef = useRef<() => void>(() => {});
+ // Beside the toast: the desktop app's native notification, a browser's system one.
+ const tellOutsideTheWindow = useCallback((key: string, fireTimeMs: number, title: string, body: string, wantsSound: boolean) => {
+ const sound = reminderSoundOn(wantsSound);
+ const { notificationSoundChoice: choice } = useSettingsStore.getState();
+ if (getDesktopBridge()?.os) {
+ // Main knows about pauses and quiet hours, and says whether a sound is in order.
+ void notifyWithoutStore({ key, fireTimeMs, title, body, sound }, { generic: t('alert_title') }, activeAccountId).then((result) => {
+ if (result?.playSound) playNotificationSound(choice);
+ });
+ return;
+ }
+ if (sound) playNotificationSound(choice);
+ void showSystemReminder({
+ key,
+ title,
+ body,
+ silent: !sound,
+ hidden: contentHiddenFor(activeAccountId) ? { title: t('alert_title') } : undefined,
+ }, `${getPathPrefix(locale)}/${locale}/calendar`);
+ }, [t, locale, activeAccountId]);
+
const checkAlerts = useCallback(() => {
if (wakeTimerRef.current) {
clearTimeout(wakeTimerRef.current);
wakeTimerRef.current = null;
}
if (!calendarNotificationsEnabled || !isAuthenticated) return;
+ // Nothing may show now, so nothing is used up: the periodic check comes back to it.
+ if (isAppLocked()) return;
try {
const now = Date.now();
@@ -128,27 +165,29 @@ export function useCalendarAlerts() {
? `${timeLabel} · ${alert.calendarName}`
: timeLabel;
- remind({
- id: alert.alertId,
- eventId: alert.eventId,
- eventTitle: alert.event.title || '',
- triggerTime: new Date(alert.fireTimeMs).toISOString(),
- }, () => {
- if (reminderSoundOn(calendarNotificationSound)) {
- playNotificationSound(notificationSoundChoice);
- }
- reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') }));
- addToast({
- type: 'info',
- title: alert.event.title || t('alert_title'),
- message,
- duration: reminderToastDuration(),
- onClick: () => {
- goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`);
- },
- onDismiss: () => {
- void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
- },
+ const uid = alert.event.uid || alert.event.originalId || alert.eventId;
+ claimAlert(claimKey(activeAccountId, uid, alert.alertId, alert.fireTimeMs), alert.fireTimeMs, () => {
+ remind({
+ id: alert.alertId,
+ eventId: alert.eventId,
+ eventTitle: alert.event.title || '',
+ triggerTime: new Date(alert.fireTimeMs).toISOString(),
+ }, () => {
+ const title = alert.event.title || t('alert_title');
+ reminderTitleAlert(t('title_alert', { title }));
+ addToast({
+ type: 'info',
+ title,
+ message,
+ duration: reminderToastDuration(),
+ onClick: () => {
+ goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`);
+ },
+ onDismiss: () => {
+ void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
+ },
+ });
+ tellOutsideTheWindow(key, alert.fireTimeMs, title, message, calendarNotificationSound);
});
});
}
@@ -167,27 +206,29 @@ export function useCalendarAlerts() {
? `${t('task_due')} · ${taskAlert.calendarName}`
: t('task_due');
- remind({
- id: taskAlert.alertId,
- taskId: taskAlert.taskId,
- taskTitle: taskAlert.task.title || '',
- triggerTime: new Date(taskAlert.fireTimeMs).toISOString(),
- }, () => {
- if (reminderSoundOn(calendarNotificationSound)) {
- playNotificationSound(notificationSoundChoice);
- }
- reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') }));
- addToast({
- type: 'info',
- title: taskAlert.task.title || t('alert_title'),
- message: taskMsg,
- duration: reminderToastDuration(),
- onClick: () => {
- goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`);
- },
- onDismiss: () => {
- void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
- },
+ const uid = taskAlert.task.uid || taskAlert.taskId;
+ claimAlert(claimKey(activeAccountId, uid, taskAlert.alertId, taskAlert.fireTimeMs), taskAlert.fireTimeMs, () => {
+ remind({
+ id: taskAlert.alertId,
+ taskId: taskAlert.taskId,
+ taskTitle: taskAlert.task.title || '',
+ triggerTime: new Date(taskAlert.fireTimeMs).toISOString(),
+ }, () => {
+ const title = taskAlert.task.title || t('alert_title');
+ reminderTitleAlert(t('title_alert', { title }));
+ addToast({
+ type: 'info',
+ title,
+ message: taskMsg,
+ duration: reminderToastDuration(),
+ onClick: () => {
+ goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`);
+ },
+ onDismiss: () => {
+ void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
+ },
+ });
+ tellOutsideTheWindow(key, taskAlert.fireTimeMs, title, taskMsg, calendarNotificationSound);
});
});
}
@@ -196,9 +237,9 @@ export function useCalendarAlerts() {
// Silently ignore alert evaluation errors
}
}, [
- calendarNotificationsEnabled, calendarNotificationSound, notificationSoundChoice,
- isAuthenticated, events, calendars, acknowledgedAlerts,
- acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks,
+ calendarNotificationsEnabled, calendarNotificationSound,
+ isAuthenticated, activeAccountId, events, calendars, acknowledgedAlerts,
+ acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, tellOutsideTheWindow,
]);
checkAlertsRef.current = checkAlerts;
@@ -212,7 +253,10 @@ export function useCalendarAlerts() {
const start = new Date(now - 10 * 60 * 1000).toISOString();
const end = new Date(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000).toISOString();
const fetched = await client.queryCalendarEvents({ after: start, before: end });
- proactiveEventsRef.current = fetched;
+ // Without synthetic ids the server answers with a series' master, whose start can
+ // be years ago: only its occurrences have a fire time. Passes real occurrences through.
+ const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss");
+ proactiveEventsRef.current = expandRecurringEvents(fetched, stamp(now - EXPAND_MARGIN_MS), stamp(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000 + EXPAND_MARGIN_MS));
lastProactiveFetchRef.current = Date.now();
checkAlertsRef.current();
} catch {
diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts
index 25450a031..6debff52c 100644
--- a/hooks/use-email-drag.ts
+++ b/hooks/use-email-drag.ts
@@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
+import { canDragNativeFiles, forgetDragBlob, keepDragBlob, keptDragBlob, startNativeFileDrag } from "@/lib/desktop/file-drag";
import { canExport } from "@/lib/device-policy";
import { appUrl, buildMailPath } from "@/lib/deep-links";
import {
@@ -103,7 +104,10 @@ function selectionKey(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions
function revokeLater(url: string): void {
// Chromium may read the URL asynchronously after the drop completes.
- setTimeout(() => URL.revokeObjectURL(url), 60_000);
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ forgetDragBlob(url);
+ }, 60_000);
}
async function buildEmailZip(emails: RoutedEmail[]): Promise {
@@ -126,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise {
}),
);
const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" });
- return URL.createObjectURL(zipBlob);
+ return keepDragBlob(URL.createObjectURL(zipBlob), zipBlob);
}
function prefetchEmailBundle(
@@ -224,8 +228,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
if (previous?.url) revokeLater(previous.url);
const entry: BundleEntry = { key: route.key, name: route.name, url: null, promise: null };
singleRef.current = entry;
- entry.promise = route.client
- .fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId)
+ // The desktop app's native drag needs the bytes as well as the address.
+ const fetched = canDragNativeFiles()
+ ? route.client.fetchBlob(target.blobId, route.name, "message/rfc822", route.accountId).then((blob) => keepDragBlob(URL.createObjectURL(blob), blob))
+ : route.client.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId);
+ entry.promise = fetched
.then((url) => {
if (url) {
if (singleRef.current === entry && routeEmail(target, filenameOptions).key === entry.key) entry.url = url;
@@ -295,6 +302,14 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const url = singleRef.current?.key === route.key ? singleRef.current.url : null;
if (url) {
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
+ // Desktop app, Alt held: the OS's own drag with the real file. A plain drag has
+ // to stay the page's, because it also moves the message to a folder.
+ const blob = e.altKey ? keptDragBlob(url) : undefined;
+ if (blob) {
+ e.preventDefault();
+ void startNativeFileDrag([{ name, blob }]);
+ return;
+ }
// `DownloadURL` format: ::. Chromium expects
// the filename raw - URL-encoding it ends up literally on disk
// (e.g. `%20` instead of a space). The sanitiser already removed
@@ -313,6 +328,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions));
const ready = getReadyBundle(routed, bundleOptions);
if (ready) {
+ const blob = e.altKey ? keptDragBlob(ready.url) : undefined;
+ if (blob) {
+ e.preventDefault();
+ void startNativeFileDrag([{ name: ready.name, blob }]);
+ return;
+ }
e.dataTransfer.setData(
"DownloadURL",
`application/zip:${ready.name}:${ready.url}`,
diff --git a/hooks/use-new-mail-alerts.ts b/hooks/use-new-mail-alerts.ts
index 0b118372c..b2fd6801f 100644
--- a/hooks/use-new-mail-alerts.ts
+++ b/hooks/use-new-mail-alerts.ts
@@ -8,6 +8,7 @@ import { playNotificationSound } from "@/lib/notification-sound";
import { announce } from "@/lib/announce";
import { startTitleAlert } from "@/lib/title-alert";
import { debug } from "@/lib/debug";
+import { desktopPlaysMailSound } from "@/lib/desktop/mail-notifications";
/**
* Mail that arrives within this time is told about once ("3 new messages"),
@@ -51,7 +52,8 @@ export function useNewMailAlerts(): void {
const s = useSettingsStore.getState();
if (s.doNotDisturb) return;
if (batchTimer === null) {
- if (s.emailNotificationsEnabled && s.emailNotificationSound) {
+ // The desktop app sounds it with the notification, in Do Not Disturb not at all.
+ if (s.emailNotificationsEnabled && s.emailNotificationSound && !desktopPlaysMailSound()) {
playNotificationSound(s.notificationSoundChoice);
}
batchTimer = setTimeout(flush, NEW_MAIL_BATCH_MS);
diff --git a/hooks/use-policy-refresh.ts b/hooks/use-policy-refresh.ts
index 3ae942946..f7115e422 100644
--- a/hooks/use-policy-refresh.ts
+++ b/hooks/use-policy-refresh.ts
@@ -1,6 +1,7 @@
'use client';
import { useEffect } from 'react';
+import { isDesktopApp } from '@/lib/desktop/bridge';
import { usePolicyStore } from '@/stores/policy-store';
/** How often an open, visible tab fetches the policy again. */
@@ -13,7 +14,9 @@ let mounted = 0;
let interval: ReturnType | null = null;
function refresh(force = false): void {
- if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
+ // A browser tab out of view waits until it's back. The desktop app in the tray is
+ // out of view for days and still has to hear about a wipe or a changed policy.
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden' && !isDesktopApp()) return;
const now = Date.now();
if (!force && now - lastRefresh < FOCUS_THROTTLE_MS) return;
const state = usePolicyStore.getState();
@@ -27,7 +30,7 @@ const onVisible = () => refresh();
/**
* Fetches the policy again when the tab comes back into view and every 15
- * minutes while it is visible, so an announcement (or any other change the
+ * minutes while it is visible (in the desktop app, also while hidden), so an announcement (or any other change the
* admin saves) reaches tabs that stay open. One timer for the page however
* many components use it.
*/
diff --git a/hooks/use-store-reminders.ts b/hooks/use-store-reminders.ts
new file mode 100644
index 000000000..3c5d0c437
--- /dev/null
+++ b/hooks/use-store-reminders.ts
@@ -0,0 +1,326 @@
+"use client";
+
+import { useEffect, useRef } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
+import { remind, reminderSoundOn, reminderTitleAlert, reminderToastDuration } from '@/hooks/use-calendar-alerts';
+import { getPathPrefix } from '@/lib/browser-navigation';
+import { onAcknowledged } from '@/lib/calendar-reminders/channel';
+import { claimKey, firedWithoutStore, noteStoreFired } from '@/lib/calendar-reminders/claims';
+import { createReminderRunner } from '@/lib/calendar-reminders/runner';
+import { reminderTimeZone, setReminderPoke, useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state';
+import { closeSystemReminders, showSystemReminder } from '@/lib/calendar-reminders/system-notification';
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { contentHiddenFor } from '@/lib/desktop/mail-notifications';
+import {
+ buildMissedNotification,
+ buildReminderNotification,
+ dismissReminders,
+ joinLabel,
+ joinMeeting,
+ missedBody,
+ SNOOZE_MINUTES,
+ snoozeReminder,
+ type ReminderLabels,
+} from '@/lib/desktop/reminder-notifications';
+import { addWakeHandler } from '@/lib/desktop/system-events';
+import { playNotificationSound } from '@/lib/notification-sound';
+import { offlineActive } from '@/lib/offline/offline-mode';
+import { visibleAccounts, useOfflineStore } from '@/lib/offline/state';
+import { addStoreEventListener } from '@/lib/offline/store-events';
+import { calendarHooks } from '@/lib/plugin-hooks';
+import type { CalendarAlertView } from '@/lib/plugin-types';
+import { isPopoutWindow } from '@/lib/popout/window-role';
+import { getStore } from '@/lib/store/client/api';
+import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useToastStore } from '@/stores/toast-store';
+
+/** Under the store's LEASE_MS, with room for a hidden tab's slow timers. */
+const LOAN_RENEW_MS = 60_000;
+
+function viewOf(alert: ReminderAlert): CalendarAlertView {
+ const triggerTime = new Date(alert.fireTimeMs).toISOString();
+ return alert.kind === 'task'
+ ? { id: alert.alertId, taskId: alert.baseEventId, taskTitle: alert.title, triggerTime }
+ : { id: alert.alertId, eventId: alert.baseEventId, eventTitle: alert.title, triggerTime };
+}
+
+function acknowledgeView(alert: ReminderAlert) {
+ return alert.kind === 'task'
+ ? { alertId: alert.alertId, taskId: alert.baseEventId }
+ : { alertId: alert.alertId, eventId: alert.baseEventId };
+}
+
+/**
+ * Reminders from the local store: on time with no network, and in the desktop app with
+ * the window hidden. For accounts without a store useCalendarAlerts carries on as before.
+ */
+export function useStoreReminders(): void {
+ const signedIn = useAuthStore((s) => s.isAuthenticated);
+ const t = useTranslations('desktopReminders');
+ const tAlert = useTranslations('calendar.notifications');
+ const locale = useLocale();
+ const addToast = useToastStore((s) => s.addToast);
+ const removeToast = useToastStore((s) => s.removeToast);
+ const enabledKey = useOfflineStore((s) => s.enabled.join('\n'));
+
+ const labels: ReminderLabels = {
+ generic: t('generic_title'),
+ untitled: tAlert('alert_title'),
+ when: (alert) => {
+ const minutes = alert.startMs === null ? 0 : Math.round((alert.startMs - Date.now()) / 60_000);
+ const time = alert.kind === 'task'
+ ? tAlert('task_due')
+ : minutes <= 0 ? tAlert('alert_now') : tAlert('alert_in_minutes', { count: minutes });
+ return alert.calendarName ? `${time} · ${alert.calendarName}` : time;
+ },
+ snooze: (minutes) => t('snooze', { count: minutes }),
+ snoozeShort: (minutes) => t('snooze_short', { count: minutes }),
+ open: t('open'),
+ join: t('join'),
+ joinHost: (host) => t('join_host', { host }),
+ missedTitle: (count) => t('missed_title', { count }),
+ missedMore: (count) => t('missed_more', { count }),
+ };
+ const labelsRef = useRef(labels);
+ labelsRef.current = labels;
+ const calendarUrl = `${getPathPrefix(locale)}/${locale}/calendar`;
+ const calendarUrlRef = useRef(calendarUrl);
+ calendarUrlRef.current = calendarUrl;
+ const runnerRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (!signedIn || isPopoutWindow()) return;
+ const toastsByKey = new Map();
+ const loans = new Set>();
+
+ const inAppReminder = (account: string, alert: ReminderAlert) => {
+ const current = labelsRef.current;
+ const desktop = !!getDesktopBridge()?.os;
+ // Set when a button says what happens next, so closing the toast doesn't undo it.
+ let handled = false;
+ const snoozeAction = (minutes: number) => ({
+ label: current.snooze(minutes),
+ onClick: () => {
+ handled = true;
+ void snoozeReminder(account, alert.key, minutes).catch(() => {});
+ },
+ });
+ const [five, ten, fifteen] = SNOOZE_MINUTES.map(snoozeAction);
+ const id = addToast({
+ type: 'info',
+ title: alert.title || current.untitled,
+ message: current.when(alert),
+ duration: reminderToastDuration(),
+ onClick: () => {
+ window.location.href = calendarUrlRef.current;
+ },
+ onDismiss: () => {
+ toastsByKey.delete(alert.key);
+ void calendarHooks.onCalendarAlertAcknowledge.emit(acknowledgeView(alert));
+ if (!handled) dismissReminders(account, [alert.key]);
+ },
+ ...(desktop ? {
+ tertiaryAction: five,
+ secondaryAction: ten,
+ action: fifteen,
+ extraActions: [
+ ...(alert.joinUrl ? [{
+ label: joinLabel(alert, current),
+ onClick: () => {
+ handled = true;
+ // Nothing opened, say a plugin stopped it: the reminder stands.
+ void joinMeeting(alert.joinUrl!).then((opened) => {
+ if (opened) dismissReminders(account, [alert.key]);
+ });
+ },
+ }] : []),
+ { label: current.open, onClick: () => { window.location.href = calendarUrlRef.current; } },
+ ],
+ } : {}),
+ });
+ toastsByKey.set(alert.key, id);
+ };
+
+ const showReminder = async (account: string, alert: ReminderAlert) => {
+ const settings = useSettingsStore.getState();
+ const sound = reminderSoundOn(settings.calendarNotificationSound);
+ const os = getDesktopBridge()?.os;
+ const hold = () => getStore().area(account, 'reminders.hold', { keys: [alert.key] }).catch(() => {});
+ if (os) {
+ const locked = isAppLocked();
+ const result = await os.notify(await buildReminderNotification(account, alert, labelsRef.current, sound)).catch(() => null);
+ // Paused, quiet hours, a toast that failed, or nothing may be said now: the summary says it later.
+ if (!result || locked || result.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'failed') {
+ await hold();
+ return;
+ }
+ if (result.reason === 'duplicate') return;
+ if (result.playSound) playNotificationSound(settings.notificationSoundChoice);
+ if (result.shown) return;
+ inAppReminder(account, alert);
+ return;
+ }
+ if (sound) playNotificationSound(settings.notificationSoundChoice);
+ reminderTitleAlert(tAlert('title_alert', { title: alert.title || labelsRef.current.untitled }));
+ inAppReminder(account, alert);
+ void showSystemReminder({
+ key: alert.key,
+ title: alert.title || labelsRef.current.untitled,
+ body: labelsRef.current.when(alert),
+ silent: !sound,
+ hidden: contentHiddenFor(account) ? { title: labelsRef.current.generic } : undefined,
+ }, calendarUrlRef.current);
+ };
+
+ const showMissed = async (account: string) => {
+ if (isAppLocked()) return;
+ const timeZone = reminderTimeZone(useSettingsStore.getState().timeZone);
+ const taken = await getStore().area(account, 'reminders.takeMissed', { timeZone });
+ if (!taken) return;
+ // What the path without a store already rang, before the store took over, was seen.
+ const rung = taken.alerts.filter((alert) => firedWithoutStore(claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs)));
+ if (rung.length > 0) await getStore().area(account, 'reminders.dismiss', { keys: rung.map((alert) => alert.key) });
+ const batch: MissedBatch = { batch: taken.batch, alerts: taken.alerts.filter((alert) => !rung.includes(alert)) };
+ if (batch.alerts.length === 0) return;
+ const current = labelsRef.current;
+ const os = getDesktopBridge()?.os;
+ if (os) {
+ const release = () => getStore().area(account, 'reminders.releaseMissed', { batch: batch.batch });
+ const result = await os.notify(await buildMissedNotification(account, batch, current)).catch(() => null);
+ // Told nobody, so it has to wait for a time when it can.
+ if (!result || result.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'duplicate' || result.reason === 'failed') {
+ await release();
+ return;
+ }
+ if (result.shown) {
+ await getStore().area(account, 'reminders.settleMissed', { batch: batch.batch });
+ return;
+ }
+ }
+ const keys = batch.alerts.map((alert) => alert.key);
+ let opened = false;
+ const body = missedBody(batch.alerts, current);
+ const id = addToast({
+ type: 'info',
+ title: current.missedTitle(batch.alerts.length),
+ message: body,
+ duration: 0,
+ action: {
+ label: t('missed_show'),
+ onClick: () => {
+ opened = true;
+ useMissedReminders.getState().show(account, batch.alerts);
+ },
+ },
+ onDismiss: () => {
+ for (const key of keys) toastsByKey.delete(key);
+ if (!opened) dismissReminders(account, keys);
+ },
+ });
+ for (const key of keys) toastsByKey.set(key, id);
+ keepOnLoan(account, batch.batch, id, keys);
+ if (!os) {
+ void showSystemReminder({
+ key: `missed:${batch.batch}`,
+ title: current.missedTitle(batch.alerts.length),
+ body,
+ silent: true,
+ hidden: contentHiddenFor(account) ? { title: current.missedTitle(batch.alerts.length) } : undefined,
+ }, calendarUrlRef.current);
+ }
+ };
+
+ // Stays with this window while it's on screen here. A reload loses the toast, the
+ // renewals stop, and the store hands the summary out again.
+ const keepOnLoan = (account: string, batch: number, toastId: string, keys: string[]) => {
+ const onScreen = () => useToastStore.getState().toasts.some((toast) => toast.id === toastId)
+ || useMissedReminders.getState().alerts.some((alert) => keys.includes(alert.key));
+ const loan = setInterval(() => {
+ if (!onScreen()) {
+ clearInterval(loan);
+ loans.delete(loan);
+ return;
+ }
+ void getStore().area(account, 'reminders.renewMissed', { batch }).catch(() => {});
+ }, LOAN_RENEW_MS);
+ loans.add(loan);
+ };
+
+ const runner = createReminderRunner({
+ accounts: () => visibleAccounts().filter((account) => offlineActive(account)),
+ store: () => getStore(),
+ params: () => {
+ const settings = useSettingsStore.getState();
+ return {
+ timeZone: reminderTimeZone(settings.timeZone),
+ tasks: settings.enableCalendarTasks,
+ muted: !settings.calendarNotificationsEnabled || !usePolicyStore.getState().isFeatureEnabled('calendarEnabled'),
+ };
+ },
+ // Only this account's client has a stream the page can see. For the others, look.
+ needsPolling: (account) => !(account === useAuthStore.getState().activeAccountId && useEmailStore.getState().isPushConnected),
+ hidden: () => document.visibilityState === 'hidden',
+ offline: () => navigator.onLine === false,
+ onDue: (account, alerts) => {
+ for (const alert of alerts) {
+ // The other path may have got there first, at a handover.
+ if (!noteStoreFired(claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs), alert.fireTimeMs)) continue;
+ remind(viewOf(alert), () => {
+ void showReminder(account, alert).catch((error) => {
+ console.error('Failed to show a reminder:', error);
+ });
+ });
+ }
+ },
+ onMissed: showMissed,
+ onSource: (account, driven) => useReminderSource.getState().setDriven(account, driven),
+ });
+ runnerRef.current = runner;
+ setReminderPoke(() => runner.poke());
+ runner.start();
+
+ const stopEvents = addStoreEventListener((event) => {
+ if (event.type === 'synced') runner.synced(event.account);
+ else if (event.type === 'changed' && event.collections.some((name) => name === 'CalendarEvent' || name === 'Calendar')) runner.poke();
+ });
+ const stopWake = addWakeHandler(() => runner.wake());
+ const onOnline = () => runner.wake();
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') runner.poke();
+ };
+ window.addEventListener('online', onOnline);
+ document.addEventListener('visibilitychange', onVisible);
+ const stopAcks = onAcknowledged((keys) => {
+ for (const key of keys) {
+ const id = toastsByKey.get(key);
+ if (id) removeToast(id);
+ toastsByKey.delete(key);
+ }
+ void closeSystemReminders(keys);
+ });
+
+ return () => {
+ runner.stop();
+ for (const loan of loans) clearInterval(loan);
+ runnerRef.current = null;
+ setReminderPoke(null);
+ stopEvents();
+ stopWake();
+ stopAcks();
+ window.removeEventListener('online', onOnline);
+ document.removeEventListener('visibilitychange', onVisible);
+ };
+ // Labels go through a ref: a language change mustn't restart the check.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [signedIn]);
+
+ // An account just turned on or off.
+ useEffect(() => {
+ runnerRef.current?.poke();
+ }, [enabledKey]);
+}
diff --git a/instrumentation.node.ts b/instrumentation.node.ts
index a1f36ff11..971184ac8 100644
--- a/instrumentation.node.ts
+++ b/instrumentation.node.ts
@@ -134,6 +134,9 @@ migrateLegacyAdminLayout()
{ name: "telemetry-errors", start: instantErrors.startInstantErrors, stop: instantErrors.stopInstantErrors },
{ name: "version-check", start: versionCheck.startScheduler, stop: versionCheck.stopScheduler },
]);
+ if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1") {
+ (await import("./lib/desktop/server-links")).installDesktopServerLinks();
+ }
})
.catch((err) => {
logger.warn("Admin dashboard init skipped", { error: err instanceof Error ? err.message : String(err) });
diff --git a/integration/tests/24-do-export.spec.ts b/integration/tests/24-do-export.spec.ts
new file mode 100644
index 000000000..682ad72fe
--- /dev/null
+++ b/integration/tests/24-do-export.spec.ts
@@ -0,0 +1,428 @@
+/**
+ * Export of the local store, through the real UI, as dso-export, against a VM 106
+ * Stalwart: a folder written with the browser offline is the bytes the server has,
+ * the private files never hold readable mail, what has no original here is counted
+ * and can be tried again online, and "No export" holds.
+ *
+ * An account of its own: other specs running at the same time work in the ent-*
+ * accounts, and folders of this one went missing there mid-run. Check or create it
+ * on the VM first (~/desktop-offline/dso_export_provision.py), then run
+ * desktop-offline-rig/run-spec.sh with IT_DSO_PASSWORD from the vault item
+ * "Stalwart test – dso-* throwaway accounts". Works in its own folder and deletes
+ * it with its mail at the end.
+ */
+import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import { adminContext, contextOptions, requireEntEnv, shot, withPolicy } from './helpers/ent';
+import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
+import { JMAP_URL } from './helpers/config';
+import { JmapClient } from './helpers/jmap';
+import { listZipEntries, readZipEntry } from '../../lib/mail-import/zip-reader';
+import { MboxSplitter } from '../../lib/mail-import/mbox';
+import { withoutStateHeaders } from '../../lib/mail-import/mbox-status';
+
+requireEntEnv();
+test.describe.configure({ mode: 'serial' });
+test.setTimeout(300_000);
+
+const USER = 'dso-export@example.org';
+const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
+if (!PASSWORD) throw new Error('Set IT_DSO_PASSWORD, and check the account on the VM first: see the top of this file');
+const STAMP = `D10-${Date.now().toString(36)}`;
+const FOLDER = `${STAMP}-folder`;
+const SECRET = `Secret${STAMP.replace(/\W/g, '')}`;
+const COUNT = 3;
+const DEVICES_OFF = { noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false, pushWithoutContent: false, watermark: false, noOffline: false };
+
+const auth = () => 'Basic ' + Buffer.from(`${USER}:${PASSWORD}`).toString('base64');
+let jmap: Promise | null = null;
+const account = () => (jmap ??= JmapClient.connect(USER, PASSWORD));
+
+/** Bytes a rebuilt message wouldn't have: a Latin-1 byte, a bare LF, odd spacing, signature-like headers, mbox traps. */
+function rawMessage(n: number): Buffer {
+ return Buffer.concat([
+ Buffer.from(`From: Seed \r\nTo: ${USER}\r\nSubject: ${SECRET} subject ${n} \r\nDate: Mon, 1 Jan 2018 08:00:0${n} +0000\r\nMessage-ID: <${STAMP}-${n}@example.org>\r\nDKIM-Signature: v=1; a=rsa-sha256; b=abc${n}\r\nContent-Type: text/plain; charset=iso-8859-1\r\n\r\n`),
+ Buffer.from([0xe4, 0x0a]),
+ Buffer.from(`From the start of a line ${n}\r\n>From quoted once\n${SECRET} body ${n}\r\n`),
+ ]);
+}
+
+let mailbox = '';
+const blobIds: string[] = [];
+const originals: Buffer[] = [];
+
+test.beforeAll(async () => {
+ const client = await account();
+ mailbox = await client.createMailbox(FOLDER);
+ for (let n = 1; n <= COUNT; n += 1) {
+ const up = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth(), 'Content-Type': 'message/rfc822' }, body: rawMessage(n) });
+ expect(up.ok).toBe(true);
+ const { blobId } = await up.json();
+ const res = await client.request([['Email/import', { accountId: client.accountId, emails: { e: { blobId, mailboxIds: { [mailbox]: true }, keywords: { $seen: true }, receivedAt: new Date(Date.now() + n * 1000).toISOString().replace(/\.\d+Z/, 'Z') } } }, '0']]);
+ const created = res.methodResponses[0][1].created?.e;
+ expect(created?.id, JSON.stringify(res.methodResponses[0][1])).toBeTruthy();
+ blobIds.push(created.blobId);
+ const download = await fetch(`${JMAP_URL}/jmap/download/${client.accountId}/${created.blobId}/m.eml?accept=message/rfc822`, { headers: { Authorization: auth() } });
+ expect(download.ok).toBe(true);
+ // What the server has is the reference, whatever it did with the upload.
+ originals.push(Buffer.from(await download.arrayBuffer()));
+ }
+});
+
+test.afterAll(async () => {
+ const client = await account();
+ await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [mailbox], onDestroyRemoveEmails: true }, '0']]).catch(() => {});
+});
+
+/** Every write to a private file is kept, and the export has no save picker, so it goes to the spool. */
+const INIT = () => {
+ const proto = FileSystemWritableFileStream.prototype as unknown as { write(data: unknown): Promise };
+ const write = proto.write;
+ const seen: Uint8Array[] = [];
+ (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites = seen;
+ proto.write = function patched(data: unknown) {
+ if (data instanceof Uint8Array) seen.push(data.slice());
+ return write.call(this, data);
+ };
+ delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker;
+};
+
+let page: Page;
+let context: BrowserContext;
+
+async function settings(target: Page): Promise {
+ await target.goto('/settings/backup/offline');
+ await expect(target.getByTestId('offline-switch')).toBeVisible({ timeout: 30_000 });
+}
+
+/** A page load with no network: the app shell from the service worker, the rest from the device. */
+async function startOffline(): Promise {
+ await page.reload();
+ await expect(page.getByTestId('offline-banner')).toContainText("You're offline", { timeout: 60_000 });
+ // The shell can open Settings on its first page: from there through the menu, as a person would.
+ if (!(await page.getByTestId('offline-switch').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true, () => false))) {
+ await page.getByText('Backup & reset', { exact: true }).first().click();
+ }
+ await expect(page.getByTestId('offline-switch')).toBeVisible({ timeout: 30_000 });
+}
+
+async function openDialog(): Promise {
+ await page.getByTestId('local-export-open').click();
+ await expect(page.getByTestId('local-export-dialog')).toBeVisible();
+ await expect(page.getByTestId('local-export-summary')).toBeVisible({ timeout: 30_000 });
+ // The test's folder only.
+ await page.getByTestId('local-export-all').click();
+ await page.locator(`[data-folder="${FOLDER}"]`).click();
+}
+
+async function closeSetup(): Promise {
+ await page.getByTestId('local-export-dialog').getByRole('button', { name: 'Cancel', exact: true }).click();
+ await expect(page.getByTestId('local-export-dialog')).toBeHidden();
+}
+
+async function closeSummary(): Promise {
+ await page.getByTestId('local-export-dialog').getByRole('button', { name: 'Close', exact: true }).last().click();
+ await expect(page.getByTestId('local-export-dialog')).toBeHidden();
+}
+
+async function waitForOriginals(wanted: string): Promise {
+ await expect.poll(async () => {
+ await openDialog();
+ const text = await page.getByTestId('local-export-kept').textContent();
+ await closeSetup();
+ return text;
+ }, { timeout: 240_000, intervals: [5_000] }).toContain(wanted);
+}
+
+async function exported(format: 'mbox' | 'eml'): Promise {
+ await page.getByTestId(`local-export-format-${format}`).check();
+ const download = page.waitForEvent('download', { timeout: 60_000 });
+ await page.getByTestId('local-export-start').click();
+ const file = await download;
+ expect(file.suggestedFilename()).toBe(`${FOLDER}.${format === 'mbox' ? 'mbox' : 'zip'}`);
+ await expect(page.getByTestId('mail-export-status')).toBeVisible();
+ return readFileSync(await file.path());
+}
+
+function mboxMessages(bytes: Buffer): Buffer[] {
+ const splitter = new MboxSplitter();
+ return [...splitter.push(new Uint8Array(bytes)), ...splitter.end()].map((message) => Buffer.concat(withoutStateHeaders(message.parts)));
+}
+
+function expectOriginals(messages: Buffer[]): void {
+ expect(messages.length).toBe(COUNT);
+ for (let i = 0; i < COUNT; i += 1) expect(messages[i].equals(originals[i]), `message ${i + 1} differs`).toBe(true);
+}
+
+/** What this run wrote to the private files, for looking inside it. */
+async function spoolBytes(): Promise {
+ const base64 = await page.evaluate(() => {
+ const writes = (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites;
+ let binary = '';
+ for (const write of writes) for (const byte of write) binary += String.fromCharCode(byte);
+ return btoa(binary);
+ });
+ return Buffer.from(base64, 'base64');
+}
+
+async function forgetSpoolWrites(): Promise {
+ await page.evaluate(() => { (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites.length = 0; });
+}
+
+async function spoolReport(): Promise<{ left: string[]; bytes: number; readable: boolean }> {
+ return page.evaluate(async (secret) => {
+ const writes = (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites;
+ const decoder = new TextDecoder('latin1');
+ const root = await navigator.storage.getDirectory();
+ const left: string[] = [];
+ try {
+ const dir = await root.getDirectoryHandle('bulwark-exports');
+ for await (const name of (dir as unknown as { keys(): AsyncIterable }).keys()) left.push(name);
+ } catch { /* never made */ }
+ return {
+ left,
+ bytes: writes.reduce((sum, write) => sum + write.length, 0),
+ readable: writes.some((write) => decoder.decode(write).includes(secret)),
+ };
+ }, SECRET);
+}
+
+test.beforeAll(async ({ browser }: { browser: Browser }) => {
+ context = await browser.newContext(contextOptions());
+ page = await context.newPage();
+ await neutralizeDevOverlay(page);
+ await page.goto('/');
+ await page.fill('#username', USER);
+ await page.fill('#password', PASSWORD);
+ await page.click('button[type="submit"]');
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
+ await context.addInitScript(INIT);
+ await settings(page);
+ const toggle = page.getByTestId('offline-switch');
+ if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click();
+ await expect(toggle).toHaveAttribute('aria-checked', 'true');
+ await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 });
+ await waitForOriginals(`${COUNT} of ${COUNT}`);
+});
+
+test.afterAll(async () => {
+ await context?.close();
+});
+
+test('a folder exported offline as mbox is the bytes the server has, and the private files hold nothing readable', async () => {
+ await context.setOffline(true);
+ await openDialog();
+ await expect(page.getByTestId('local-export-kept')).toContainText(`${COUNT} of ${COUNT}`);
+ await expect(page.getByTestId('local-export-missing')).toHaveCount(0);
+ await shot(page, 'd10-export-setup');
+ const mbox = await exported('mbox');
+ expectOriginals(mboxMessages(mbox));
+ await expect(page.getByTestId('mail-export-status')).toContainText(`Messages exported: ${COUNT}`);
+ await shot(page, 'd10-export-done');
+ const report = await spoolReport();
+ expect(report.bytes).toBeGreaterThan(0);
+ expect(report.readable).toBe(false);
+ // Gone the moment the export was done, with the download still free to read its Blob.
+ expect(report.left).toEqual([]);
+ await closeSummary();
+});
+
+test('the same folder as a ZIP of .eml files has the same bytes', async () => {
+ await forgetSpoolWrites();
+ await openDialog();
+ const zip = new Blob([new Uint8Array(await exported('eml'))]);
+ const entries = await listZipEntries(zip);
+ expect(entries.map((entry) => entry.name).every((name) => name.startsWith(`${FOLDER}/`) && name.endsWith('.eml'))).toBe(true);
+ expectOriginals(await Promise.all(entries.map(async (entry) => Buffer.from(await readZipEntry(zip, entry)))));
+ const report = await spoolReport();
+ expect(report.left).toEqual([]);
+ // Deflated mail wouldn't show up in a search for its text: it has to be no ZIP at all.
+ const spool = await spoolBytes();
+ expect(spool.length).toBeGreaterThan(0);
+ expect(spool.includes(Buffer.from('PK\x03\x04', 'latin1'))).toBe(false);
+ expect(spool.includes(Buffer.from(FOLDER))).toBe(false);
+ await expect(listZipEntries(new Blob([new Uint8Array(spool)]))).rejects.toThrow();
+ await closeSummary();
+});
+
+/**
+ * The picker's dialog is the only fake: what it answers is a real file handle, with
+ * a real writable and a real remove. `keepRemove: false` is a browser without remove.
+ */
+async function pickerAnswers(name: string, keepRemove = true): Promise {
+ await page.evaluate(async ({ name: file, keepRemove: keep }) => {
+ const root = await navigator.storage.getDirectory();
+ const window_ = window as unknown as { showSaveFilePicker: unknown; __pickerClicks: number };
+ window_.__pickerClicks = 0;
+ window_.showSaveFilePicker = async () => {
+ // The real one refuses a call that lost the click.
+ if (!navigator.userActivation.isActive) throw new DOMException('Must be handling a user gesture', 'SecurityError');
+ window_.__pickerClicks += 1;
+ const handle = await root.getFileHandle(file, { create: true });
+ return keep ? handle : { name: handle.name, createWritable: () => handle.createWritable() };
+ };
+ }, { name, keepRemove });
+}
+
+async function pickedFile(name: string): Promise {
+ const base64 = await page.evaluate(async (file) => {
+ const root = await navigator.storage.getDirectory();
+ try {
+ const bytes = new Uint8Array(await (await (await root.getFileHandle(file)).getFile()).arrayBuffer());
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary);
+ } catch {
+ return null;
+ }
+ }, name);
+ return base64 === null ? null : Buffer.from(base64, 'base64');
+}
+
+async function noPicker(): Promise {
+ await page.evaluate(() => { delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; });
+}
+
+test('with a save picker, it writes straight to the chosen file, from the click, and no spool is left', async () => {
+ await pickerAnswers(`${STAMP}-picked.mbox`);
+ await openDialog();
+ await page.getByTestId('local-export-format-mbox').check();
+ await page.getByTestId('local-export-start').click();
+ await expect(page.getByTestId('mail-export-status')).toContainText(`Messages exported: ${COUNT}`);
+ expect(await page.evaluate(() => (window as unknown as { __pickerClicks: number }).__pickerClicks)).toBe(1);
+ expectOriginals(mboxMessages((await pickedFile(`${STAMP}-picked.mbox`))!));
+ expect((await spoolReport()).left).toEqual([]);
+ await closeSummary();
+ await noPicker();
+});
+
+test('with attachments off it says offline why it can\'t; a connection lost during the run is counted; the retry writes them all', async () => {
+ await context.setOffline(false);
+ await page.getByTestId('offline-attachments').click();
+ await expect(page.getByTestId('offline-attachments')).toHaveAttribute('aria-checked', 'false');
+ // A choice reaches the store after a short wait, so a quick change of mind costs nothing.
+ await waitForOriginals(`0 of ${COUNT}`);
+ await context.setOffline(true);
+ await openDialog();
+ await expect(page.getByTestId('local-export-kept')).toContainText(`0 of ${COUNT}`);
+ await expect(page.getByTestId('local-export-missing')).toContainText(`offline: ${COUNT}`);
+ await expect(page.getByTestId('local-export-reason')).toContainText('Keep attachments');
+ // Nothing could be written, so it doesn't offer to.
+ await expect(page.getByTestId('local-export-start')).toBeDisabled();
+ await shot(page, 'd10-export-offline-missing');
+
+ // The browser says online, and the server goes as the export starts.
+ await context.setOffline(false);
+ await expect(page.getByTestId('local-export-start')).toBeEnabled({ timeout: 30_000 });
+ const SERVER = `${new URL(JMAP_URL).origin}/**`;
+ await context.route(SERVER, (route) => route.abort('internetdisconnected'));
+ let downloads = 0;
+ const counted = () => { downloads += 1; };
+ page.on('download', counted);
+ await page.getByTestId('local-export-format-mbox').check();
+ await page.getByTestId('local-export-start').click();
+ await expect(page.getByTestId('local-export-not-kept-attachments')).toContainText(`attachments aren't kept on this device: ${COUNT}`, { timeout: 60_000 });
+ await expect(page.getByTestId('mail-export-status')).toContainText('Messages exported: 0');
+ // No empty file that looks like a backup.
+ await expect(page.getByTestId('mail-export-file')).toHaveCount(0);
+ await expect(page.getByTestId('mail-export-download')).toHaveCount(0);
+ page.off('download', counted);
+ expect(downloads).toBe(0);
+ await shot(page, 'd10-export-not-kept');
+
+ await context.unroute(SERVER);
+ // The browser going off says so in the summary, and coming back opens the retry.
+ await context.setOffline(true);
+ await expect(page.getByTestId('local-export-retry')).toBeDisabled();
+ await expect(page.getByTestId('local-export-retry-hint')).toHaveText("You can try again when you're online.");
+ await context.setOffline(false);
+ await expect(page.getByTestId('local-export-retry')).toBeEnabled({ timeout: 60_000 });
+ await page.getByTestId('local-export-retry').click();
+ // The same folder is still the one chosen.
+ await expect(page.getByTestId('local-export-kept')).toContainText(`0 of ${COUNT}`, { timeout: 30_000 });
+ await expect(page.getByTestId('local-export-missing')).toContainText(`To download from the server: ${COUNT}`);
+ const again = await exported('mbox');
+ expectOriginals(mboxMessages(again));
+ await closeSummary();
+
+ // The same with a save picker: a run that wrote nothing takes the file the picker made with it.
+ const removed = `${STAMP}-removed.mbox`;
+ const stays = `${STAMP}-stays.mbox`;
+ for (const [file, keepRemove] of [[removed, true], [stays, false]] as const) {
+ await pickerAnswers(file, keepRemove);
+ await openDialog();
+ await page.getByTestId('local-export-format-mbox').check();
+ // The server goes once it has counted as reachable: offline with nothing here there's no Export to click.
+ await expect(page.getByTestId('local-export-start')).toBeEnabled({ timeout: 60_000 });
+ await context.route(SERVER, (route) => route.abort('internetdisconnected'));
+ await page.getByTestId('local-export-start').click();
+ await expect(page.getByTestId('local-export-not-kept-attachments')).toBeVisible({ timeout: 60_000 });
+ await context.unroute(SERVER);
+ if (keepRemove) {
+ expect(await pickedFile(file), 'the picked file is gone').toBeNull();
+ await expect(page.getByTestId('local-export-leftover')).toHaveCount(0);
+ } else {
+ await expect(page.getByTestId('local-export-leftover')).toContainText(file);
+ await shot(page, 'd10-export-leftover');
+ }
+ await context.setOffline(true);
+ await context.setOffline(false);
+ await closeSummary();
+ }
+ await noPicker();
+
+ await page.getByTestId('offline-attachments').click();
+ await expect(page.getByTestId('offline-attachments')).toHaveAttribute('aria-checked', 'true');
+});
+
+test('at 390 px the dialog fits, and works by keyboard', async () => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.getByTestId('local-export-open').scrollIntoViewIfNeeded();
+ await page.getByTestId('local-export-open').focus();
+ await page.keyboard.press('Enter');
+ const dialog = page.getByTestId('local-export-dialog');
+ await expect(dialog).toBeVisible();
+ await expect(page.getByTestId('local-export-summary')).toBeVisible({ timeout: 30_000 });
+ const widths = await page.evaluate(() => {
+ const panel = document.querySelector('[data-testid="local-export-dialog"]') as HTMLElement;
+ return { page: document.documentElement.scrollWidth, panel: panel.getBoundingClientRect().width, inner: panel.scrollWidth };
+ });
+ expect(widths.page).toBeLessThanOrEqual(390);
+ expect(widths.panel).toBeLessThanOrEqual(390);
+ expect(widths.inner).toBeLessThanOrEqual(390);
+ await shot(page, 'd10-export-phone');
+ await page.keyboard.press('Escape');
+ await expect(dialog).toBeHidden();
+ await page.setViewportSize({ width: 1440, height: 900 });
+});
+
+test('"No export" locks it, also once offline', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ try {
+ await withPolicy(admin, { organisation: { devices: { ...DEVICES_OFF, noExport: true } } }, async () => {
+ await settings(page);
+ await expect(page.getByTestId('local-export-open')).toBeDisabled({ timeout: 30_000 });
+ await expect(page.getByText('Set by your organisation').first()).toBeVisible();
+ // A start with no network: all it has is the policy it kept from the last fetch.
+ await context.setOffline(true);
+ await startOffline();
+ await expect(page.getByTestId('local-export-open')).toBeDisabled({ timeout: 30_000 });
+ await expect(page.getByTestId('local-export-dialog')).toHaveCount(0);
+ await shot(page, 'd10-export-locked-offline-start');
+ await context.setOffline(false);
+ });
+ } finally {
+ await admin.close();
+ }
+ await settings(page);
+ await expect(page.getByTestId('local-export-open')).toBeEnabled({ timeout: 30_000 });
+ // The other way round: a start with no network, allowed by the kept policy, exports.
+ await waitForOriginals(`${COUNT} of ${COUNT}`);
+ await context.setOffline(true);
+ await startOffline();
+ await expect(page.getByTestId('local-export-open')).toBeEnabled({ timeout: 60_000 });
+ await openDialog();
+ expectOriginals(mboxMessages(await exported('mbox')));
+ await closeSummary();
+ await context.setOffline(false);
+});
diff --git a/integration/tests/24-do-reminders.spec.ts b/integration/tests/24-do-reminders.spec.ts
new file mode 100644
index 000000000..44976d7e6
--- /dev/null
+++ b/integration/tests/24-do-reminders.spec.ts
@@ -0,0 +1,226 @@
+import { test, expect, type BrowserContext, type Page } from '@playwright/test';
+import { ENT, jmapAs, requireEntEnv, shot, userPage } from './helpers/ent';
+
+/**
+ * Calendar reminders from the local store, in a real browser on a production build and
+ * a VM 106 test Stalwart, with real time passing: the store's worker has its own clock.
+ *
+ * The account is ent-dave, whom the other specs leave alone. Snooze and the native
+ * buttons belong to the desktop app: repos/desktop test/reminders-real.spec.ts.
+ */
+
+test.describe.configure({ mode: 'serial' });
+test.setTimeout(900_000);
+// The headless shell denies notifications; full Chromium really shows them.
+test.use({ channel: 'chromium' });
+
+const RUN = Date.now().toString(36);
+const USER = ENT.dave;
+const ZONE = 'Europe/Berlin';
+const CORE = 'urn:ietf:params:jmap:core';
+const CALENDARS = 'urn:ietf:params:jmap:calendars';
+/** ledger.ts AWAY_MS and a margin. */
+const AWAY_MS = 6.5 * 60_000;
+/** How long a change on the server may take to reach the store. */
+const SYNC_MS = 30_000;
+
+type J = Awaited>;
+let client: J;
+let context: BrowserContext;
+let page: Page;
+const created: string[] = [];
+
+async function call(calls: [string, Record, string][]) {
+ const response = await client.request(calls, [CORE, CALENDARS]);
+ for (const [name, result] of response.methodResponses) {
+ if (name === 'error') throw new Error(`JMAP error: ${JSON.stringify(result)}`);
+ }
+ return response.methodResponses as [string, Record, string][];
+}
+
+/** A wall clock in the event's zone. */
+function wall(ms: number): string {
+ const parts = new Intl.DateTimeFormat('sv-SE', {
+ timeZone: ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
+ }).format(new Date(ms));
+ return parts.replace(' ', 'T');
+}
+
+const OFFSET_MIN = 5;
+
+/** An event whose alert fires at `fireAt`. */
+async function eventFiringAt(title: string, fireAt: number, extra: Record = {}): Promise {
+ const [[, calendars]] = await call([['Calendar/get', { accountId: client.accountId, properties: ['isDefault'] }, '0']]);
+ const list = calendars.list as Array<{ id: string; isDefault: boolean }>;
+ const calendar = list.find((entry) => entry.isDefault) ?? list[0];
+ const [[, result]] = await call([['CalendarEvent/set', {
+ accountId: client.accountId,
+ create: {
+ e: {
+ '@type': 'Event',
+ calendarIds: { [calendar.id]: true },
+ timeZone: ZONE,
+ title,
+ start: wall(fireAt + OFFSET_MIN * 60_000),
+ duration: 'PT30M',
+ useDefaultAlerts: false,
+ alerts: { a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: `-PT${OFFSET_MIN}M`, relativeTo: 'start' } } },
+ ...extra,
+ },
+ },
+ sendSchedulingMessages: false,
+ }, '0']]);
+ const id = (result.created as Record | undefined)?.e?.id;
+ if (!id) throw new Error(`Event not created: ${JSON.stringify(result)}`);
+ created.push(id);
+ return id;
+}
+
+/** Settings > Backup & reset > Offline, switched on, and the first sync done. */
+async function enableOffline(target: Page): Promise {
+ await target.goto('/settings/backup/offline');
+ const toggle = target.getByTestId('offline-switch');
+ await toggle.waitFor({ state: 'visible', timeout: 60_000 });
+ if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click();
+ await expect(target.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 });
+}
+
+const toast = (target: Page, text: string) => target.locator('.toast-item').filter({ hasText: text });
+
+test.beforeAll(async ({ browser }) => {
+ requireEntEnv();
+ client = await jmapAs(USER);
+ page = await userPage(browser, USER);
+ context = page.context();
+ await enableOffline(page);
+ await page.goto('/');
+});
+
+test.afterAll(async () => {
+ if (created.length > 0) {
+ await call([['CalendarEvent/set', { accountId: client.accountId, destroy: created, sendSchedulingMessages: false }, '0']]).catch(() => {});
+ }
+ await context?.close();
+});
+
+test('an occurrence of a series fires from the store with the network gone', async () => {
+ const title = `rem-series-${RUN}`;
+ const fireAt = Date.now() + SYNC_MS + 30_000;
+ // The series began weeks ago, so only an expansion finds today's occurrence.
+ const [day, time] = wall(fireAt + OFFSET_MIN * 60_000).split('T');
+ const weeksAgo = new Date(Date.parse(`${day}T00:00:00Z`) - 28 * 24 * 3_600_000).toISOString().slice(0, 10);
+ await eventFiringAt(title, fireAt, {
+ start: `${weeksAgo}T${time}`,
+ recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' },
+ });
+ await page.waitForTimeout(SYNC_MS);
+ await context.setOffline(true);
+ try {
+ expect(await page.evaluate(() => navigator.onLine)).toBe(false);
+ await page.waitForTimeout(Math.max(fireAt - Date.now() - 3000, 0));
+ await expect(toast(page, title)).toHaveCount(0);
+ await expect(toast(page, title)).toBeVisible({ timeout: 30_000 });
+ await shot(page, 'desktop-reminders-offline-toast');
+ } finally {
+ await context.setOffline(false);
+ }
+ await toast(page, title).getByRole('button').first().click().catch(() => {});
+});
+
+test('fires in one tab of two', async () => {
+ const second = await context.newPage();
+ await second.goto('/');
+ const title = `rem-tabs-${RUN}`;
+ const fireAt = Date.now() + SYNC_MS + 20_000;
+ await eventFiringAt(title, fireAt);
+ // A reminder's toast leaves after a while, so note the most each tab ever showed.
+ const most = [0, 0];
+ const deadline = fireAt + 40_000;
+ let lastSeen = 0;
+ while (Date.now() < deadline && (lastSeen === 0 || Date.now() < lastSeen + 8000)) {
+ const counts = await Promise.all([page, second].map((target) => toast(target, title).count()));
+ counts.forEach((count, index) => {
+ most[index] = Math.max(most[index], count);
+ });
+ if (lastSeen === 0 && counts.some((count) => count > 0)) lastSeen = Date.now();
+ await second.waitForTimeout(250);
+ }
+ expect(most[0] + most[1]).toBe(1);
+ await second.close();
+ await page.bringToFront();
+});
+
+test('adds a system notification when permission is granted and the tab is not in front', async () => {
+ await context.grantPermissions(['notifications']);
+ await page.reload();
+ await page.evaluate(() => {
+ document.hasFocus = () => false;
+ });
+ const title = `rem-system-${RUN}`;
+ const fireAt = Date.now() + SYNC_MS + 15_000;
+ await eventFiringAt(title, fireAt);
+ await expect(toast(page, title)).toBeVisible({ timeout: fireAt - Date.now() + 30_000 });
+ const shown = await page.evaluate(async (wanted) => {
+ const registration = await navigator.serviceWorker.ready;
+ return (await registration.getNotifications()).filter((item) => item.title === wanted).map((item) => ({ tag: item.tag, data: item.data as unknown }));
+ }, title);
+ expect(shown).toHaveLength(1);
+ expect(shown[0].tag).toContain('calendar-reminder:');
+ expect(shown[0].data).toMatchObject({ kind: 'calendar-reminder', url: expect.stringMatching(/\/calendar$/) });
+ await context.clearPermissions();
+});
+
+test('what came due with the app closed is summarised once, and not as a burst', async () => {
+ const one = `rem-missed-a-${RUN}`;
+ const two = `rem-missed-b-${RUN}`;
+ const fireAt = Date.now() + SYNC_MS + 20_000;
+ await eventFiringAt(one, fireAt);
+ await eventFiringAt(two, fireAt + 20_000, { virtualLocations: { v: { '@type': 'VirtualLocation', name: 'Meet', uri: 'https://meet.jit.si/rem-test' } } });
+ await page.waitForTimeout(SYNC_MS);
+
+ // Closed like an app: nothing of the page is left, and the store's data stays.
+ const closedAt = Date.now();
+ await page.close();
+ await new Promise((resolve) => setTimeout(resolve, Math.max(fireAt + 20_000 + AWAY_MS - closedAt, AWAY_MS)));
+
+ page = await context.newPage();
+ await page.goto('/');
+ const summary = page.locator('.toast-item').filter({ hasText: one });
+ await expect(summary).toBeVisible({ timeout: 60_000 });
+ await expect(summary).toContainText(two);
+ await expect(toast(page, one)).toHaveCount(1);
+ await shot(page, 'desktop-reminders-missed-toast');
+
+ await summary.getByRole('button', { name: 'Show' }).click();
+ const list = page.getByTestId('missed-reminders');
+ await expect(list).toContainText(one);
+ await expect(list).toContainText(two);
+ await expect(list.getByRole('button', { name: 'Join' })).toHaveCount(1);
+ await shot(page, 'desktop-reminders-missed');
+ await page.getByRole('button', { name: 'Done' }).click();
+ await expect(list).toHaveCount(0);
+
+ // Once: not after a reload, nor from a new tab.
+ await page.reload();
+ await page.waitForTimeout(30_000);
+ await expect(page.locator('.toast-item').filter({ hasText: 'rem-missed' })).toHaveCount(0);
+ await expect(page.getByTestId('missed-reminders')).toHaveCount(0);
+});
+
+test('with the store off, the app reminds as it always did', async () => {
+ await page.goto('/settings/backup/offline');
+ const toggle = page.getByTestId('offline-switch');
+ await toggle.waitFor({ state: 'visible', timeout: 60_000 });
+ await toggle.click();
+ const confirm = page.getByRole('button', { name: /turn off|switch off|delete/i }).last();
+ if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click();
+ await expect(toggle).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 });
+ await page.goto('/');
+ const title = `rem-plain-${RUN}`;
+ const fireAt = Date.now() + 20_000;
+ await eventFiringAt(title, fireAt);
+ // The in-app path reads the server every five minutes, or on a push.
+ await expect(toast(page, title)).toBeVisible({ timeout: 120_000 });
+ await page.waitForTimeout(5000);
+ await expect(toast(page, title)).toHaveCount(1);
+});
diff --git a/lib/__tests__/collector-validator.ts b/lib/__tests__/collector-validator.ts
new file mode 100644
index 000000000..9e895510e
--- /dev/null
+++ b/lib/__tests__/collector-validator.ts
@@ -0,0 +1,26 @@
+// Not a test: loads the collector's real validator for the tests that check against it.
+import { spawnSync } from 'node:child_process';
+import { existsSync, mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+export interface CollectorValidator {
+ validate(input: unknown): { schema: string; build: string | null; platform: string; version: string; os_family: string; node_version: string | null } | null;
+ validateErrorBatch(input: unknown): { errors: { name: string; count: string; days: number; version: string; frames: unknown[] }[] } | null;
+}
+
+/**
+ * The validator on the branch the desktop app ships against, read from git so it doesn't
+ * matter what the dashboard checkout has out. Null where there's no dashboard repo (CI).
+ */
+export async function loadCollectorValidator(): Promise {
+ const repo = process.env.BULWARK_DASHBOARD_DIR ?? 'C:/Users/info/Downloads/jmap-webmail/repos/dashboard';
+ const ref = process.env.BULWARK_DASHBOARD_REF ?? 'feat/desktop-platform';
+ if (!existsSync(repo)) return null;
+ const shown = spawnSync('git', ['-C', repo, 'show', `${ref}:telemetry-collector/src/validate.ts`], { encoding: 'utf8' });
+ if (shown.status !== 0) throw new Error(`no validator at ${ref}: ${shown.stderr}`);
+ const file = path.join(mkdtempSync(path.join(tmpdir(), 'bw-validator-')), 'validate.ts');
+ writeFileSync(file, shown.stdout);
+ return await import(/* @vite-ignore */ pathToFileURL(file).href) as CollectorValidator;
+}
diff --git a/lib/__tests__/telemetry-desktop-endpoint.test.ts b/lib/__tests__/telemetry-desktop-endpoint.test.ts
new file mode 100644
index 000000000..7fa525dd2
--- /dev/null
+++ b/lib/__tests__/telemetry-desktop-endpoint.test.ts
@@ -0,0 +1,129 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// What's on disk can't aim the desktop app's telemetry anywhere: a state file or a config
+// file is something any program on the machine can write. Nothing here reaches a network.
+
+const flag = vi.hoisted(() => ({ IS_DESKTOP_BUILD: true, dsn: '' }));
+vi.mock('@/lib/desktop/flag', () => ({
+ get IS_DESKTOP_BUILD() {
+ return flag.IS_DESKTOP_BUILD;
+ },
+}));
+vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => false }));
+vi.mock('@/lib/admin/plugin-registry', () => ({
+ getPluginRegistry: async () => ({ plugins: [] }),
+ getThemeRegistry: async () => ({ themes: [] }),
+}));
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: async () => {},
+ getPolicy: () => ({ features: {} }),
+ getAll: () => ({}),
+ get: (key: string, fallback: unknown) => (key === 'errorReportingDsn' ? flag.dsn : fallback),
+ },
+}));
+const sent = vi.fn(async (_url: string, _init?: { body?: string }) => new Response('{"ok":true}'));
+vi.mock('@/lib/telemetry/endpoint-guard', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveEndpointAllowed: vi.fn(async () => ({ ok: true })),
+ fetchTelemetryTarget: (url: string, init?: { body?: string }) => sent(url, init),
+ };
+});
+
+import { DEFAULT_ENDPOINT, loadState, saveState, sendOnce } from '@/lib/telemetry';
+import { resetInstantErrorsForTests, sendInstantErrors } from '@/lib/telemetry/instant-errors';
+import { flushErrors, recordError, resetAggregateForTests } from '@/lib/error-reporting/aggregate';
+import { browserErrorSettings, errorReportingSettings } from '@/lib/error-reporting/settings';
+import { captureDesktopMainError } from '@/lib/error-reporting/server';
+
+const PRIVACY = Symbol.for('bulwark.desktop.privacy');
+const holder = globalThis as unknown as Record;
+const EVIL = 'https://collector.evil.example/v1/heartbeat';
+const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.evil.example/3';
+
+let dir = '';
+
+beforeEach(async () => {
+ flag.IS_DESKTOP_BUILD = true;
+ flag.dsn = '';
+ sent.mockClear();
+ dir = mkdtempSync(path.join(tmpdir(), 'bw-telemetry-endpoint-'));
+ vi.stubEnv('TELEMETRY_DATA_DIR', dir);
+ vi.stubEnv('BULWARK_TELEMETRY', '');
+ resetAggregateForTests();
+ resetInstantErrorsForTests();
+ holder[PRIVACY] = { telemetry: true, crashReports: true, appVersion: '0.3.0', build: 'desktop.standalone.stable' };
+ await saveState({ consent: 'off', endpoint: EVIL, consentedAt: null, lastSentAt: null, nextScheduledAt: null });
+});
+
+afterEach(() => {
+ delete holder[PRIVACY];
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('the desktop build', () => {
+ it('sends a heartbeat to the project\'s collector, whatever the state file names', async () => {
+ expect((await sendOnce()).ok).toBe(true);
+ expect(sent.mock.calls.map(([url]) => url)).toEqual([DEFAULT_ENDPOINT]);
+ });
+
+ it('isn\'t switched off by a blank endpoint in the state file either: consent is the switch', async () => {
+ await saveState({ ...(await loadState()), endpoint: '' });
+ expect((await sendOnce()).ok).toBe(true);
+ expect(sent.mock.calls.map(([url]) => url)).toEqual([DEFAULT_ENDPOINT]);
+ });
+
+ it('sends error counts there too', async () => {
+ recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
+ await flushErrors();
+ await sendInstantErrors();
+ expect(sent.mock.calls.map(([url]) => url)).toEqual([new URL('errors', DEFAULT_ENDPOINT).toString()]);
+ });
+
+ it('has no DSN, whatever a config file names', async () => {
+ flag.dsn = DSN;
+ expect(errorReportingSettings().dsn).toBe('');
+ expect((await browserErrorSettings()).dsn).toBe('');
+ });
+
+ it('sends a main-process error to no config-file DSN, with consent on', async () => {
+ flag.dsn = DSN;
+ const fetchSpy = vi.fn().mockResolvedValue(new Response(null));
+ vi.stubGlobal('fetch', fetchSpy);
+ await captureDesktopMainError({ name: 'TypeError', message: 'x', stack: 'TypeError: x\n at f (/desktop/main.mjs:1:2)' });
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe('a server build', () => {
+ beforeEach(() => {
+ flag.IS_DESKTOP_BUILD = false;
+ vi.stubEnv('BULWARK_TELEMETRY', 'on');
+ });
+
+ it('still uses the endpoint its admin stored', async () => {
+ await saveState({ ...(await loadState()), endpoint: 'https://collector.example.org/v1/heartbeat' });
+ await sendOnce();
+ expect(sent.mock.calls.map(([url]) => url)).toEqual(['https://collector.example.org/v1/heartbeat']);
+ });
+
+ it('still takes a blank endpoint as off', async () => {
+ await saveState({ ...(await loadState()), endpoint: '' });
+ expect(await sendOnce()).toMatchObject({ ok: false, error: 'endpoint blank' });
+ expect(sent).not.toHaveBeenCalled();
+ });
+
+ it('still has its admin\'s DSN', async () => {
+ flag.dsn = DSN;
+ expect(errorReportingSettings().dsn).toBe(DSN);
+ expect((await browserErrorSettings()).dsn).toBe(DSN);
+ });
+});
diff --git a/lib/__tests__/telemetry-desktop.test.ts b/lib/__tests__/telemetry-desktop.test.ts
new file mode 100644
index 000000000..9d1c66860
--- /dev/null
+++ b/lib/__tests__/telemetry-desktop.test.ts
@@ -0,0 +1,397 @@
+// @vitest-environment node
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { createServer, type Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// The desktop app's heartbeat and error counts, on a real socket: what counts is what
+// reaches the collector, so these listen on a port rather than stubbing fetch. Consent
+// comes from the shell, not from a file or the environment. The bodies go through the
+// collector's real validator.
+
+const flag = vi.hoisted(() => ({ IS_DESKTOP_BUILD: true }));
+vi.mock('@/lib/desktop/flag', () => ({
+ get IS_DESKTOP_BUILD() {
+ return flag.IS_DESKTOP_BUILD;
+ },
+}));
+vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => false }));
+vi.mock('@/lib/admin/plugin-registry', () => ({
+ getPluginRegistry: async () => ({ plugins: [] }),
+ getThemeRegistry: async () => ({ themes: [] }),
+}));
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: async () => {},
+ getPolicy: () => ({ features: { calendarEnabled: true } }),
+ getAll: () => ({}),
+ get: (_key: string, fallback: unknown) => fallback,
+ },
+}));
+
+import { buildPayload, loadState, saveState, sendOnce, startScheduler, stopScheduler } from '@/lib/telemetry';
+import { resetInstantErrorsForTests, sendInstantErrors } from '@/lib/telemetry/instant-errors';
+import { flushErrors, recordError, resetAggregateForTests } from '@/lib/error-reporting/aggregate';
+import { shouldCollectErrors } from '@/lib/error-reporting/settings';
+import { fetchTelemetryTarget } from '@/lib/telemetry/endpoint-guard';
+import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
+import { DESKTOP_CATCH_UP_MAX_MS, DESKTOP_CATCH_UP_MIN_MS } from '@/lib/desktop/desktop-telemetry';
+import { loadCollectorValidator } from './collector-validator';
+
+const collector = await loadCollectorValidator();
+
+interface Received {
+ path: string;
+ body: Record;
+}
+
+let server: Server;
+let received: Received[] = [];
+let answer = 200;
+let base = '';
+let dir = '';
+
+const PRIVACY = Symbol.for('bulwark.desktop.privacy');
+const TRUSTED = Symbol.for('bulwark.desktop.trustedJmapOrigins');
+const holder = globalThis as unknown as Record;
+
+function shellSays(privacy: { telemetry: boolean; crashReports: boolean } | null): void {
+ holder[PRIVACY] = privacy ? { ...privacy, appVersion: '0.3.0', build: 'desktop.standalone.beta', endpoint: `${base}/v1/heartbeat` } : undefined;
+}
+
+beforeAll(async () => {
+ server = createServer((req, res) => {
+ let raw = '';
+ req.on('data', (chunk) => { raw += chunk; });
+ req.on('end', () => {
+ received.push({ path: req.url ?? '', body: JSON.parse(raw) });
+ res.writeHead(answer, { 'content-type': 'application/json' });
+ res.end('{"ok":true}');
+ });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+});
+
+afterAll(async () => {
+ await new Promise((resolve) => server.close(resolve));
+});
+
+beforeEach(async () => {
+ flag.IS_DESKTOP_BUILD = true;
+ received = [];
+ answer = 200;
+ dir = mkdtempSync(path.join(tmpdir(), 'bw-telemetry-desktop-'));
+ vi.stubEnv('TELEMETRY_DATA_DIR', dir);
+ vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', '1');
+ vi.stubEnv('BULWARK_TELEMETRY', '');
+ vi.stubEnv('BULWARK_TELEMETRY_DISABLED', '');
+ vi.stubEnv('BULWARK_TELEMETRY_ERRORS', '');
+ shellSays(null);
+ resetAggregateForTests();
+ resetInstantErrorsForTests();
+ // Never used on the desktop build. Reaching it would show up as a failed send.
+ await saveState({ consent: 'off', endpoint: 'http://127.0.0.1:1/ignored', consentedAt: null, lastSentAt: null, nextScheduledAt: null });
+});
+
+afterEach(async () => {
+ await stopScheduler();
+ delete holder[PRIVACY];
+ delete holder[TRUSTED];
+ vi.unstubAllEnvs();
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('nothing leaves before consent', () => {
+ it('sends no heartbeat when the shell has said nothing', async () => {
+ expect((await sendOnce()).ok).toBe(false);
+ expect(received).toEqual([]);
+ });
+
+ it('sends no heartbeat on a no from the shell, whatever the file or the environment say', async () => {
+ shellSays({ telemetry: false, crashReports: false });
+ vi.stubEnv('BULWARK_TELEMETRY', 'on');
+ await saveState({ ...(await loadState()), consent: 'on' });
+ expect((await sendOnce()).ok).toBe(false);
+ expect(received).toEqual([]);
+ });
+
+ it('sends no error counts when the shell has said nothing', async () => {
+ recordError({ name: 'TypeError', frames: [] }, 'server', '1.0.0');
+ await flushErrors();
+ await sendInstantErrors();
+ expect(received).toEqual([]);
+ expect(await shouldCollectErrors()).toBe(false);
+ });
+});
+
+describe('the heartbeat with consent', () => {
+ it('goes out, and stops the moment consent goes', async () => {
+ shellSays({ telemetry: true, crashReports: false });
+ expect((await sendOnce()).ok).toBe(true);
+ expect(received).toHaveLength(1);
+ expect(received[0].path).toBe('/v1/heartbeat');
+ shellSays({ telemetry: false, crashReports: false });
+ expect((await sendOnce()).ok).toBe(false);
+ expect(received).toHaveLength(1);
+ });
+
+ it('says desktop, the app version, mode and channel, in schema 1', async () => {
+ shellSays({ telemetry: true, crashReports: false });
+ await sendOnce();
+ expect(received[0].body).toMatchObject({ schema: '1', platform: 'desktop', version: '0.3.0', build: 'desktop.standalone.beta' });
+ expect(received[0].body).not.toHaveProperty('errors');
+ expect(received[0].body).not.toHaveProperty('mode');
+ expect(received[0].body).not.toHaveProperty('channel');
+ });
+
+ it.skipIf(!collector)('is what the real collector accepts, and it keeps the build', async () => {
+ shellSays({ telemetry: true, crashReports: false });
+ await sendOnce();
+ const clean = collector!.validate(received[0].body)!;
+ expect(clean).not.toBeNull();
+ expect(clean.schema).toBe('1');
+ expect(clean.build).toBe('desktop.standalone.beta');
+ expect(clean.version).toBe('0.3.0');
+ expect(clean.platform).toBe('desktop');
+ // Schema 2 refuses any extra key, so this proves there is none.
+ expect(collector!.validate({ ...received[0].body, schema: '2', errors: [] })).not.toBeNull();
+ });
+
+ it('stays schema 1 with crash reports on too', async () => {
+ shellSays({ telemetry: true, crashReports: true });
+ expect((await buildPayload()).schema).toBe('1');
+ });
+});
+
+describe('quit within an hour of each start', () => {
+ const nextIn = async () => new Date((await loadState()).nextScheduledAt!).getTime() - Date.now();
+
+ it.each([
+ [0, DESKTOP_CATCH_UP_MIN_MS],
+ [0.5, (DESKTOP_CATCH_UP_MIN_MS + DESKTOP_CATCH_UP_MAX_MS) / 2],
+ [0.999999, DESKTOP_CATCH_UP_MAX_MS],
+ ])('after a restart with a slot in the past, at random %s it goes after about %s ms, not an hour', async (random, expected) => {
+ shellSays({ telemetry: true, crashReports: false });
+ vi.spyOn(Math, 'random').mockReturnValue(random);
+ await saveState({ ...(await loadState()), nextScheduledAt: new Date(Date.now() - 3 * 86_400_000).toISOString() });
+ await startScheduler();
+ const wait = await nextIn();
+ vi.restoreAllMocks();
+ expect(wait).toBeGreaterThan(expected - 5_000);
+ expect(wait).toBeLessThanOrEqual(expected);
+ });
+
+ it('the wait is between 30 s and 10 min, and differs from start to start', () => {
+ expect(DESKTOP_CATCH_UP_MIN_MS).toBe(30_000);
+ expect(DESKTOP_CATCH_UP_MAX_MS).toBe(600_000);
+ });
+
+ it('the first run with consent does not wait an hour either', async () => {
+ shellSays({ telemetry: true, crashReports: false });
+ await startScheduler();
+ expect(await nextIn()).toBeLessThanOrEqual(DESKTOP_CATCH_UP_MAX_MS);
+ });
+
+ it('a slot still ahead stays where it is', async () => {
+ shellSays({ telemetry: true, crashReports: false });
+ const slot = new Date(Date.now() + 5 * 3_600_000).toISOString();
+ await saveState({ ...(await loadState()), nextScheduledAt: slot });
+ await startScheduler();
+ expect(await nextIn()).toBeGreaterThan(4 * 3_600_000);
+ });
+
+ it('a server build still waits the hour', async () => {
+ flag.IS_DESKTOP_BUILD = false;
+ vi.stubEnv('BULWARK_TELEMETRY', 'on');
+ await saveState({ ...(await loadState()), endpoint: `${base}/v1/heartbeat`, nextScheduledAt: new Date(Date.now() - 86_400_000).toISOString() });
+ await startScheduler();
+ const wait = await nextIn();
+ expect(wait).toBeGreaterThan(59 * 60_000);
+ expect(wait).toBeLessThanOrEqual(3_600_000);
+ });
+});
+
+describe('BULWARK_TELEMETRY=off', () => {
+ beforeEach(() => {
+ vi.stubEnv('BULWARK_TELEMETRY', 'off');
+ shellSays({ telemetry: true, crashReports: true });
+ });
+
+ it('beats the user\'s yes for the heartbeat', async () => {
+ expect((await sendOnce()).ok).toBe(false);
+ expect(received).toEqual([]);
+ });
+
+ it('beats it for error counts too, and nothing is collected', async () => {
+ expect(await shouldCollectErrors()).toBe(false);
+ recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
+ await flushErrors();
+ await sendInstantErrors();
+ expect(received).toEqual([]);
+ });
+
+ it('the legacy variable does the same', async () => {
+ vi.stubEnv('BULWARK_TELEMETRY', '');
+ vi.stubEnv('BULWARK_TELEMETRY_DISABLED', '1');
+ expect((await sendOnce()).ok).toBe(false);
+ expect(await shouldCollectErrors()).toBe(false);
+ expect(received).toEqual([]);
+ });
+
+ it('=on is no yes by itself', async () => {
+ vi.stubEnv('BULWARK_TELEMETRY', 'on');
+ shellSays({ telemetry: false, crashReports: false });
+ expect((await sendOnce()).ok).toBe(false);
+ expect(received).toEqual([]);
+ });
+
+ it('the list of outgoing hosts says how the desktop app differs', () => {
+ const entry = OUTGOING_HOSTS.find((host) => host.id === 'telemetry')!;
+ expect(entry.usedBy).toMatch(/crash report consent alone/i);
+ expect(entry.off).toMatch(/desktop app/i);
+ expect(entry.off).toMatch(/no admin page/i);
+ });
+});
+
+describe('a refusal from the collector', () => {
+ it('isn\'t retried on every tick', async () => {
+ answer = 400;
+ shellSays({ telemetry: false, crashReports: true });
+ recordError({ name: 'TypeError', frames: [] }, 'server', '0.3.0-beta.1+desktop');
+ await flushErrors();
+ await sendInstantErrors();
+ await sendInstantErrors();
+ await sendInstantErrors();
+ expect(received).toHaveLength(1);
+ });
+
+ it('a passing failure is tried again', async () => {
+ answer = 503;
+ shellSays({ telemetry: false, crashReports: true });
+ recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
+ await flushErrors();
+ await sendInstantErrors();
+ await sendInstantErrors();
+ expect(received).toHaveLength(2);
+ });
+
+ it('rate limiting is tried again', async () => {
+ answer = 429;
+ shellSays({ telemetry: false, crashReports: true });
+ recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
+ await flushErrors();
+ await sendInstantErrors();
+ await sendInstantErrors();
+ expect(received).toHaveLength(2);
+ });
+
+ it.skipIf(!collector)('a beta version with the tag is what the patched collector takes', async () => {
+ shellSays({ telemetry: false, crashReports: true });
+ recordError({ name: 'TypeError', frames: [] }, 'server', '0.3.0-beta.1+desktop');
+ await flushErrors();
+ await sendInstantErrors();
+ expect(collector!.validateErrorBatch(received[0].body)!.errors[0].version).toBe('0.3.0-beta.1+desktop');
+ });
+
+ it('a server build still retries as before', async () => {
+ flag.IS_DESKTOP_BUILD = false;
+ vi.stubEnv('BULWARK_TELEMETRY', 'on');
+ vi.stubEnv('BULWARK_TELEMETRY_ERRORS', 'instant');
+ answer = 400;
+ await saveState({ ...(await loadState()), endpoint: `${base}/v1/heartbeat` });
+ recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0');
+ await flushErrors();
+ await sendInstantErrors();
+ await sendInstantErrors();
+ expect(received).toHaveLength(2);
+ });
+});
+
+describe('crash reports have their own consent', () => {
+ const mainFrame = { path: '/desktop/main.mjs', line: 4, column: 2 };
+ const bundleFrame = { path: '/.next/server/chunks/1.js', line: 9, column: 1 };
+
+ it('collects and sends error counts with telemetry off, and no heartbeat', async () => {
+ shellSays({ telemetry: false, crashReports: true });
+ expect(await shouldCollectErrors()).toBe(true);
+ recordError({ name: 'TypeError', frames: [bundleFrame] }, 'server', '1.20.0+desktop');
+ await flushErrors();
+ await sendInstantErrors();
+ expect(received.map((r) => r.path)).toEqual(['/v1/errors']);
+ expect((await sendOnce()).ok).toBe(false);
+ expect(received).toHaveLength(1);
+ });
+
+ it('telemetry alone collects no errors', async () => {
+ shellSays({ telemetry: true, crashReports: false });
+ expect(await shouldCollectErrors()).toBe(false);
+ recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
+ await flushErrors();
+ await sendInstantErrors();
+ expect(received).toEqual([]);
+ });
+
+ it.skipIf(!collector)('a batch with a main-process error is what the real collector accepts', async () => {
+ shellSays({ telemetry: false, crashReports: true });
+ recordError({ name: 'TypeError', frames: [mainFrame, bundleFrame] }, 'server', '1.20.0+desktop');
+ await flushErrors();
+ await sendInstantErrors();
+ const clean = collector!.validateErrorBatch(received[0].body)!;
+ expect(clean).not.toBeNull();
+ expect(clean.errors[0].version).toBe('1.20.0+desktop');
+ // The unpatched collector refuses a whole batch over a frame outside the bundles.
+ expect(clean.errors[0].frames).toEqual([expect.objectContaining({ path: '/.next/server/chunks/1.js' })]);
+ });
+
+ it('keeps main-process frames in what stays on this computer', async () => {
+ shellSays({ telemetry: false, crashReports: true });
+ recordError({ name: 'TypeError', frames: [mainFrame] }, 'server', '1.20.0+desktop');
+ await flushErrors();
+ const stored = JSON.parse(readFileSync(path.join(dir, 'errors.json'), 'utf8')) as Record>;
+ const entry = Object.values(Object.values(stored)[0])[0];
+ expect(entry.frames[0].path).toBe('/desktop/main.mjs');
+ });
+});
+
+describe('the endpoint guard', () => {
+ it('still refuses a private address when the shell trusts it as a mail server', async () => {
+ vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', '');
+ holder[TRUSTED] = new Set([base, 'http://192.168.1.5']);
+ await expect(fetchTelemetryTarget(`${base}/v1/heartbeat`, { method: 'POST', body: '{}' })).rejects.toThrow();
+ await expect(fetchTelemetryTarget('http://192.168.1.5/v1/heartbeat', { method: 'POST', body: '{}' })).rejects.toThrow();
+ expect(received).toEqual([]);
+ });
+
+ it('a stored endpoint on a private address goes nowhere with consent', async () => {
+ vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', '');
+ shellSays({ telemetry: true, crashReports: false });
+ holder[TRUSTED] = new Set([base]);
+ const result = await sendOnce();
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('endpoint blocked');
+ expect(received).toEqual([]);
+ });
+});
+
+describe('a server build', () => {
+ it('ignores what a shell would have said, so the admin\'s consent stands', async () => {
+ flag.IS_DESKTOP_BUILD = false;
+ shellSays({ telemetry: true, crashReports: true });
+ vi.stubEnv('BULWARK_TELEMETRY', '');
+ expect((await sendOnce()).ok).toBe(false);
+ expect(received).toEqual([]);
+ expect(await shouldCollectErrors()).toBe(false);
+ await saveState({ ...(await loadState()), consent: 'on', endpoint: `${base}/v1/heartbeat` });
+ expect((await sendOnce()).ok).toBe(true);
+ expect(received[0].body.platform).not.toBe('desktop');
+ expect(received[0].body.build).not.toBe('desktop.standalone.beta');
+ });
+});
+
+// So a typo in the path can't quietly turn the checks above off on a machine with the repo.
+it.skipIf(process.env.CI)('finds the collector on this machine', () => {
+ expect(collector).not.toBeNull();
+});
diff --git a/lib/admin/__tests__/server-device-offline-caps.test.ts b/lib/admin/__tests__/server-device-offline-caps.test.ts
index 1b9ba98e4..1364b243b 100644
--- a/lib/admin/__tests__/server-device-offline-caps.test.ts
+++ b/lib/admin/__tests__/server-device-offline-caps.test.ts
@@ -7,15 +7,15 @@ import { sanitizeOrganisationPartial } from '../organisation-policy';
const scope = (id: string, devices: Record) => ({ id, label: id, match: { domains: [`${id}.example.org`] }, policy: { organisation: { devices } } });
-describe('wipeOnRefusedLogin', () => {
+describe('wipeOnForcedSignOut', () => {
it('is off unless asked for, and survives the sanitizer', () => {
- expect(DEFAULT_POLICY.organisation?.devices?.wipeOnRefusedLogin ?? false).toBe(false);
- expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: true } }).devices).toMatchObject({ wipeOnRefusedLogin: true });
- expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: 'yes' } }).devices?.wipeOnRefusedLogin).not.toBe(true);
+ expect(DEFAULT_POLICY.organisation?.devices?.wipeOnForcedSignOut ?? false).toBe(false);
+ expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: true } }).devices).toMatchObject({ wipeOnForcedSignOut: true });
+ expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: 'yes' } }).devices?.wipeOnForcedSignOut).not.toBe(true);
});
it('isn\'t set by the stand-in for a login nobody can read: that\'s for what stays on the server', () => {
- expect(ALL_DEVICE_LIMITS.wipeOnRefusedLogin).toBe(false);
+ expect(ALL_DEVICE_LIMITS.wipeOnForcedSignOut).toBe(false);
});
});
diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts
index e8e6f5d4f..0fe813574 100644
--- a/lib/admin/organisation-policy.ts
+++ b/lib/admin/organisation-policy.ts
@@ -176,11 +176,11 @@ export interface OrganisationPolicy {
/** All attachments and files of one account on a device, in MB. 0 is no cap. */
offlineMaxTotalMb: number;
/**
- * A device deletes an account's offline data as soon as the server refuses its
- * login, instead of hiding it for 30 days until the new password. Unsent mail
- * goes with it.
+ * A device deletes an account's offline data as soon as it signs out with nobody
+ * asking (the server refused the login, or the idle or session limit ran out),
+ * instead of hiding it for 30 days until the next sign-in. Unsent mail goes with it.
*/
- wipeOnRefusedLogin: boolean;
+ wipeOnForcedSignOut: boolean;
};
calendars: {
/** Adds up across scopes by id. */
@@ -206,7 +206,7 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = {
noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false,
pushWithoutContent: false, watermark: false, noOffline: false,
offlineMaxDays: 0, noOfflineAttachments: false, offlineMaxAttachmentMb: 0, offlineMaxTotalMb: 0,
- wipeOnRefusedLogin: false,
+ wipeOnForcedSignOut: false,
},
calendars: { feeds: [], freeBusyGroup: '' },
};
@@ -551,7 +551,7 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial Usage stats (switch it off or clear the collector URL).',
+ usedBy: 'Anonymous daily heartbeat, sent only after an admin turned telemetry on; with instant error counts on, also the current day\'s error counts as they change. In the desktop app the user decides: the heartbeat with its usage info switch, and with crash report consent alone the error counts, with no heartbeat.',
+ off: 'Off by default. BULWARK_TELEMETRY=off, or System > Usage stats (switch it off or clear the collector URL). The desktop app has no admin page and takes no collector URL: its two switches in Settings, the TelemetryAllowed and CrashReportsAllowed policies, or BULWARK_TELEMETRY=off in its environment, which stops both.',
inMode: 'Never sent, whatever the telemetry setting says.',
lite: false,
},
diff --git a/lib/admin/server-device-policy.ts b/lib/admin/server-device-policy.ts
index 49b62b53e..7027c7222 100644
--- a/lib/admin/server-device-policy.ts
+++ b/lib/admin/server-device-policy.ts
@@ -93,7 +93,7 @@ export const ALL_DEVICE_LIMITS: ServerDevicePolicy = {
...Object.fromEntries(
Object.entries(DEFAULT_ORGANISATION_POLICY.devices).map(([key, off]) => [key, typeof off === 'number' ? 1 : true]),
),
- wipeOnRefusedLogin: false,
+ wipeOnForcedSignOut: false,
} as ServerDevicePolicy;
/**
diff --git a/lib/app-lock/__tests__/controller.test.ts b/lib/app-lock/__tests__/controller.test.ts
index 25384dc21..9d193247c 100644
--- a/lib/app-lock/__tests__/controller.test.ts
+++ b/lib/app-lock/__tests__/controller.test.ts
@@ -508,6 +508,23 @@ describe('the desktop app', () => {
expect(isAppLockedNow()).toBe(false);
});
+ it('tells the OS integration when it locks, and its check says so until the unlock', async () => {
+ bridge();
+ const setLocked = vi.fn();
+ (window as unknown as { bulwarkDesktop: Record }).bulwarkDesktop.os = { setLocked };
+ await enableAppLock(PIN);
+ expect(setLocked).not.toHaveBeenCalled();
+ await lockedAndReloaded();
+ expect(setLocked).toHaveBeenCalledWith(true);
+ setLocked.mockClear();
+ asNewDocument();
+ expect(setLocked, 'a new page behind the lock says so at once').toHaveBeenCalledWith(true);
+ expect(isAppLocked()).toBe(true);
+ await unlockWithPin(PIN);
+ // What components/desktop/desktop-os-host.tsx tells main as the app starts.
+ expect(isAppLocked()).toBe(false);
+ });
+
it('a page takes the two calls a frame must not reach once, before any frame runs', async () => {
const appLock = bridge();
bootAppLock();
diff --git a/lib/app-lock/controller.ts b/lib/app-lock/controller.ts
index 068fc265f..df9478caa 100644
--- a/lib/app-lock/controller.ts
+++ b/lib/app-lock/controller.ts
@@ -288,8 +288,10 @@ async function settleCheck(): Promise {
lockedFromNow();
}
+/** Main's gate, and the OS integration's own state: tray, jump list and toasts (the shell's os-hub.ts). */
function tellDesktopLocked(): void {
desktopControls()?.lock();
+ getDesktopBridge()?.os?.setLocked(true);
}
/** The service worker shows push notifications with no page around. Kept across its restarts. */
diff --git a/lib/calendar-reminders/__tests__/claims.test.ts b/lib/calendar-reminders/__tests__/claims.test.ts
new file mode 100644
index 000000000..7e60f078c
--- /dev/null
+++ b/lib/calendar-reminders/__tests__/claims.test.ts
@@ -0,0 +1,142 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { claimAlert, claimKey, claimNow, CLAIMS_STORAGE_KEY, firedWithoutStore, HIDDEN_GRACE_MS, noteStoreFired } from '../claims';
+
+const FIRE = Date.UTC(2026, 10, 10, 9);
+
+function setVisibility(state: 'visible' | 'hidden') {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state });
+}
+
+/** A Web Lock: one holder at a time, across "tabs". */
+function fakeLocks() {
+ let tail: Promise = Promise.resolve();
+ return {
+ request: vi.fn((_name: string, callback: () => T | Promise) => {
+ const run = tail.then(callback);
+ tail = run.catch(() => {});
+ return run;
+ }),
+ };
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ setVisibility('visible');
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ Reflect.deleteProperty(navigator, 'locks');
+});
+
+describe('claimNow', () => {
+ it('says yes once', () => {
+ const key = claimKey('acc', 'uid-1', 'a', FIRE);
+ expect(claimNow(key, FIRE)).toBe(true);
+ expect(claimNow(key, FIRE)).toBe(false);
+ });
+
+ it('tells occurrences and accounts apart', () => {
+ expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE)).toBe(true);
+ expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE + 1000), FIRE + 1000)).toBe(true);
+ expect(claimNow(claimKey('other', 'uid-1', 'a', FIRE), FIRE)).toBe(true);
+ });
+
+ it('forgets what is a day old', () => {
+ vi.useFakeTimers({ now: FIRE });
+ claimNow('old', FIRE - 25 * 3_600_000);
+ claimNow('new', FIRE);
+ expect(Object.keys(JSON.parse(localStorage.getItem(CLAIMS_STORAGE_KEY)!))).toEqual(['new']);
+ });
+
+ it('tells what the store path rang from what the other path rang', () => {
+ const key = claimKey('acc', 'uid-1', 'a', FIRE);
+ expect(noteStoreFired(key, FIRE)).toBe(true);
+ // The path without a store won't ring it again...
+ expect(claimNow(key, FIRE)).toBe(false);
+ // ...and a summary still may, since the store path keeps its own record.
+ expect(firedWithoutStore(key)).toBe(false);
+ expect(noteStoreFired(key, FIRE)).toBe(true);
+ const other = claimKey('acc', 'uid-2', 'a', FIRE);
+ claimNow(other, FIRE);
+ expect(firedWithoutStore(other)).toBe(true);
+ expect(noteStoreFired(other, FIRE)).toBe(false);
+ });
+
+ it('holds only the key, nothing of the event', () => {
+ claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE);
+ expect(localStorage.getItem(CLAIMS_STORAGE_KEY)).toBe(JSON.stringify({ [`acc:uid-1:a:${FIRE}`]: FIRE }));
+ });
+});
+
+describe('claimAlert', () => {
+ it('reads and writes the list only with the lock held, one tab at a time', async () => {
+ let release = () => {};
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ const asked: string[] = [];
+ let queue: Promise = held;
+ Object.defineProperty(navigator, 'locks', {
+ configurable: true,
+ value: {
+ request: (name: string, callback: () => T | Promise) => {
+ asked.push(name);
+ const run = queue.then(callback);
+ queue = run;
+ return run;
+ },
+ },
+ });
+ const won = vi.fn();
+ claimAlert('k', FIRE, () => won('tab 1'));
+ claimAlert('k', FIRE, () => won('tab 2'));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ // Another tab has the lock: nothing has looked at the list yet.
+ expect(localStorage.getItem(CLAIMS_STORAGE_KEY)).toBeNull();
+ expect(won).not.toHaveBeenCalled();
+ expect(asked).toEqual(['bulwark-calendar-alert-claim', 'bulwark-calendar-alert-claim']);
+
+ release();
+ await vi.waitFor(() => expect(won).toHaveBeenCalled());
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(won.mock.calls).toEqual([['tab 1']]);
+ });
+
+ it('still rings when the lock fails, rather than losing the reminder', async () => {
+ Object.defineProperty(navigator, 'locks', { configurable: true, value: { request: () => Promise.reject(new Error('Locks are off in this context')) } });
+ const won = vi.fn();
+ claimAlert('k', FIRE, won);
+ await vi.waitFor(() => expect(won).toHaveBeenCalledTimes(1));
+ });
+
+ it('runs at once where there is no Web Lock', () => {
+ const won = vi.fn();
+ claimAlert('k', FIRE, won);
+ expect(won).toHaveBeenCalledTimes(1);
+ });
+
+ it('lets a tab in view go first', () => {
+ vi.useFakeTimers({ now: FIRE });
+ setVisibility('hidden');
+ const hidden = vi.fn();
+ claimAlert('k', FIRE, hidden);
+ setVisibility('visible');
+ const visible = vi.fn();
+ claimAlert('k', FIRE, visible);
+ expect(visible).toHaveBeenCalledTimes(1);
+ vi.advanceTimersByTime(HIDDEN_GRACE_MS);
+ expect(hidden).not.toHaveBeenCalled();
+ });
+
+ it('still fires in a hidden tab when no other took it', () => {
+ vi.useFakeTimers({ now: FIRE });
+ setVisibility('hidden');
+ const won = vi.fn();
+ claimAlert('k', FIRE, won);
+ expect(won).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(HIDDEN_GRACE_MS);
+ expect(won).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/calendar-reminders/__tests__/runner.test.ts b/lib/calendar-reminders/__tests__/runner.test.ts
new file mode 100644
index 000000000..a9639f899
--- /dev/null
+++ b/lib/calendar-reminders/__tests__/runner.test.ts
@@ -0,0 +1,356 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
+import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
+import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger';
+import { HIDDEN_GRACE_MS } from '../claims';
+import { createReminderRunner, SUMMARY_WAIT_MS, type ReminderRunner, type ReminderRunnerEnv } from '../runner';
+
+const MIN = 60_000;
+const ACCOUNT = 'acc';
+const START = Date.UTC(2026, 10, 10, 9);
+const FIRE = START - 15 * MIN;
+const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } };
+
+function event(partial: Record = {}) {
+ return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial };
+}
+
+interface Tab {
+ runner: ReminderRunner;
+ due: ReminderAlert[];
+ firedAt: number[];
+ missed: MissedBatch[];
+ source: boolean[];
+ env: ReminderRunnerEnv;
+ hidden: boolean;
+ offline: boolean;
+ muted: boolean;
+}
+
+let store: InProcessStore;
+let server: FakeCalendarJmap;
+const tabs: Tab[] = [];
+
+function tab(options: Partial> = {}): Tab {
+ const made = { due: [], firedAt: [], missed: [], source: [], hidden: false, offline: false, muted: false, ...options } as unknown as Tab;
+ made.env = {
+ accounts: () => [ACCOUNT],
+ store: () => store,
+ params: () => ({ timeZone: 'Europe/Berlin', tasks: true, muted: made.muted }),
+ needsPolling: () => false,
+ hidden: () => made.hidden,
+ offline: () => made.offline,
+ onDue: (_account, alerts) => {
+ made.due.push(...alerts);
+ made.firedAt.push(...alerts.map(() => Date.now()));
+ },
+ onMissed: async (account) => {
+ const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' });
+ if (!batch) return;
+ made.missed.push(batch);
+ await store.area(account, 'reminders.settleMissed', { batch: batch.batch });
+ },
+ onSource: (_account, driven) => {
+ made.source.push(driven);
+ },
+ };
+ made.runner = createReminderRunner(made.env);
+ tabs.push(made);
+ return made;
+}
+
+async function syncedStore(...events: Record[]): Promise {
+ for (const partial of events) server.addEvent(partial);
+ await store.sync(ACCOUNT);
+}
+
+beforeEach(() => {
+ vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
+ server = new FakeCalendarJmap('1.0');
+ store = new InProcessStore(server, CALENDAR_SESSION);
+});
+
+afterEach(() => {
+ for (const made of tabs.splice(0)) made.runner.stop();
+ vi.useRealTimers();
+});
+
+describe('firing from the store', () => {
+ it('fires at the moment it is due, with the network gone', async () => {
+ await syncedStore(event());
+ await store.setOnline(false);
+ server.request = (() => {
+ throw new Error('A reminder needs no server');
+ }) as unknown as FakeCalendarJmap['request'];
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1000);
+ expect(one.due).toEqual([]);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(one.due.map((alert) => alert.title)).toEqual(['Standup']);
+ expect(one.firedAt).toEqual([FIRE]);
+ });
+
+ it('takes over from the old path once the store has synced, and gives it back when the store fails', async () => {
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.source.at(-1)).toBe(false);
+ await syncedStore(event());
+ one.runner.poke();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.source.at(-1)).toBe(true);
+ store.failArea = () => true;
+ one.runner.poke();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.source.at(-1)).toBe(false);
+ });
+
+ it('fires in one tab only, when both ask at the same moment', async () => {
+ await syncedStore(event());
+ const [one, two] = [tab(), tab()];
+ one.runner.start();
+ two.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN);
+ expect(one.due.length + two.due.length).toBe(1);
+ });
+
+ it('leaves it to the tab in view when another is hidden', async () => {
+ await syncedStore(event());
+ const hidden = tab({ hidden: true });
+ const visible = tab();
+ hidden.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ visible.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS + MIN);
+ expect(visible.due).toHaveLength(1);
+ expect(hidden.due).toHaveLength(0);
+ });
+
+ it('still fires when only a hidden tab is left', async () => {
+ await syncedStore(event());
+ const hidden = tab({ hidden: true });
+ hidden.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS);
+ expect(hidden.due).toHaveLength(1);
+ });
+
+ it('notices an event that just arrived, and a snooze just set', async () => {
+ await syncedStore();
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 2 * MIN);
+ await syncedStore(event());
+ one.runner.poke();
+ await vi.advanceTimersByTimeAsync(2 * MIN);
+ expect(one.firedAt).toEqual([FIRE]);
+
+ await store.area(ACCOUNT, 'reminders.snooze', { key: one.due[0].key, minutes: 5 });
+ one.runner.poke();
+ await vi.advanceTimersByTimeAsync(5 * MIN);
+ expect(one.firedAt).toEqual([FIRE, FIRE + 5 * MIN]);
+ });
+
+ it('brings a snoozed reminder back after a restart', async () => {
+ await syncedStore(event());
+ const before = tab();
+ before.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now());
+ await store.area(ACCOUNT, 'reminders.snooze', { key: before.due[0].key, minutes: 10 });
+ before.runner.stop();
+ await vi.advanceTimersByTimeAsync(MIN);
+
+ const after = tab();
+ after.runner.start();
+ await vi.advanceTimersByTimeAsync(10 * MIN);
+ expect(before.due).toHaveLength(1);
+ expect(after.firedAt).toEqual([FIRE + 10 * MIN]);
+ expect(after.due[0].snoozedUntil).toBe(FIRE + 10 * MIN);
+ });
+
+ it('stays quiet while reminders are off', async () => {
+ await syncedStore(event());
+ const one = tab();
+ one.muted = true;
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN);
+ expect(one.due).toEqual([]);
+ });
+});
+
+describe('a tab out of view', () => {
+ it('waits before any check, also after a wake, so the tab in view gets the alert', async () => {
+ await syncedStore(event());
+ const hidden = tab({ hidden: true });
+ const visible = tab();
+ hidden.runner.start();
+ await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS);
+ visible.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ vi.setSystemTime(FIRE + MIN);
+ hidden.runner.wake();
+ visible.runner.wake();
+ await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS);
+ expect(hidden.due).toEqual([]);
+ expect(visible.due).toHaveLength(1);
+ });
+
+ it('waits on a poke and on a sync too', async () => {
+ await syncedStore(event());
+ const hidden = tab({ hidden: true });
+ hidden.runner.start();
+ await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS);
+ const area = vi.spyOn(store, 'area');
+ hidden.runner.poke();
+ hidden.runner.synced(ACCOUNT);
+ await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS - 1);
+ expect(area).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(area).toHaveBeenCalledTimes(1);
+ });
+
+ it('a stream of pokes cannot put it off for ever', async () => {
+ await syncedStore(event());
+ const hidden = tab({ hidden: true });
+ hidden.runner.start();
+ await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS);
+ const area = vi.spyOn(store, 'area');
+ for (let i = 0; i < 5; i += 1) {
+ hidden.runner.poke();
+ await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS / 2);
+ }
+ expect(area).toHaveBeenCalled();
+ });
+});
+
+describe('going down in the middle', () => {
+ it('keeps what the store just handed over when the page stopped meanwhile', async () => {
+ await syncedStore(event());
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1);
+ const area = store.area.bind(store);
+ vi.spyOn(store, 'area').mockImplementationOnce(async (account: string, name: string, params?: unknown) => {
+ const result = await area(account, name, params);
+ one.runner.stop();
+ return result as never;
+ });
+ await vi.advanceTimersByTimeAsync(1);
+ expect(one.due).toEqual([]);
+ // Not lost: it waits for the summary.
+ await vi.advanceTimersByTimeAsync(0);
+ const next = tab({ offline: true });
+ next.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(next.missed.flatMap((batch) => batch.alerts.map((alert) => alert.title))).toEqual(['Standup']);
+ });
+
+ it('checks again after showing one threw', async () => {
+ await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review', start: '2026-11-10T10:05:00' }));
+ const one = tab();
+ const plain = one.env.onDue;
+ let first = true;
+ one.env.onDue = (account, alerts) => {
+ if (first) {
+ first = false;
+ throw new Error('A plugin threw');
+ }
+ plain(account, alerts);
+ };
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(FIRE - Date.now() + 5 * MIN);
+ expect(one.due.map((alert) => alert.title)).toEqual(['Review']);
+ });
+});
+
+describe('after being away', () => {
+ async function awayFromBothAlerts(): Promise {
+ await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ const before = tab();
+ before.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ before.runner.stop();
+ vi.setSystemTime(FIRE + 60 * MIN);
+ return before;
+ }
+
+ it('waits a moment for the first sync, then summarises once, not as a burst', async () => {
+ await awayFromBothAlerts();
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS - 1000);
+ expect(one.missed).toEqual([]);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(one.due).toEqual([]);
+ expect(one.missed.map((batch) => batch.alerts.map((alert) => alert.title))).toEqual([['Standup', 'Review']]);
+
+ await vi.advanceTimersByTimeAsync(30 * MIN);
+ one.runner.wake();
+ await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS);
+ expect(one.missed).toHaveLength(1);
+ });
+
+ it('does not wait once the sync is done, and has nothing to wait for offline', async () => {
+ await awayFromBothAlerts();
+ const synced = tab();
+ synced.runner.start();
+ await vi.advanceTimersByTimeAsync(100);
+ synced.runner.synced(ACCOUNT);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(synced.missed).toHaveLength(1);
+ });
+
+ it('gives the summary at once with no network', async () => {
+ await awayFromBothAlerts();
+ const offline = tab({ offline: true });
+ offline.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(offline.missed).toHaveLength(1);
+ });
+
+ it('gives the summary to one window of two, and neither again after a restart', async () => {
+ await awayFromBothAlerts();
+ const [one, two] = [tab({ offline: true }), tab({ offline: true })];
+ one.runner.start();
+ two.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.missed.length + two.missed.length).toBe(1);
+ one.runner.stop();
+ two.runner.stop();
+ await vi.advanceTimersByTimeAsync(MIN);
+ const next = tab({ offline: true });
+ next.runner.start();
+ await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS);
+ expect(next.missed).toEqual([]);
+ });
+
+ it('sees a jump of the clock without being told, as after a sleep', async () => {
+ await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
+ const one = tab();
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(MIN);
+ // Asleep: timers stood still and the clock did not.
+ vi.setSystemTime(FIRE + 60 * MIN);
+ await vi.advanceTimersByTimeAsync(MIN + SUMMARY_WAIT_MS);
+ expect(one.due).toEqual([]);
+ expect(one.missed.map((batch) => batch.alerts.length)).toEqual([1 + 1]);
+ });
+
+ it('tries a summary again that could not be shown', async () => {
+ await awayFromBothAlerts();
+ const one = tab({ offline: true });
+ let refuse = true;
+ const plain = one.env.onMissed;
+ one.env.onMissed = async (account) => {
+ if (!refuse) return plain(account);
+ const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' });
+ await store.area(account, 'reminders.releaseMissed', { batch: batch.batch });
+ };
+ one.runner.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(one.missed).toEqual([]);
+ refuse = false;
+ await vi.advanceTimersByTimeAsync(5 * MIN);
+ expect(one.missed).toHaveLength(1);
+ });
+});
diff --git a/lib/calendar-reminders/channel.ts b/lib/calendar-reminders/channel.ts
new file mode 100644
index 000000000..9e32f74ac
--- /dev/null
+++ b/lib/calendar-reminders/channel.ts
@@ -0,0 +1,32 @@
+/**
+ * Telling the other windows that a reminder has been dealt with, so its toast and system
+ * notification go there too. What's done is recorded in the store; this only clears what's
+ * on screen. Only keys cross it, and they say nothing about the event but its uid.
+ */
+
+const CHANNEL_NAME = 'bulwark-calendar-reminders';
+
+interface AcknowledgedMessage {
+ kind: 'acknowledged';
+ keys: string[];
+}
+
+function open(): BroadcastChannel | null {
+ return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL_NAME);
+}
+
+export function announceAcknowledged(keys: readonly string[]): void {
+ const channel = open();
+ if (!channel) return;
+ channel.postMessage({ kind: 'acknowledged', keys: [...keys] } satisfies AcknowledgedMessage);
+ channel.close();
+}
+
+export function onAcknowledged(listener: (keys: string[]) => void): () => void {
+ const channel = open();
+ if (!channel) return () => {};
+ channel.onmessage = (event: MessageEvent) => {
+ if (event.data?.kind === 'acknowledged' && Array.isArray(event.data.keys)) listener(event.data.keys);
+ };
+ return () => channel.close();
+}
diff --git a/lib/calendar-reminders/claims.ts b/lib/calendar-reminders/claims.ts
new file mode 100644
index 000000000..d50dc4035
--- /dev/null
+++ b/lib/calendar-reminders/claims.ts
@@ -0,0 +1,95 @@
+/**
+ * One tab fires a reminder when there's no local store to decide it. The list sits in
+ * localStorage and holds no more than the event's uid, and the Web Lock makes reading
+ * and writing it one step across tabs: their timers run out at the same moment, so a
+ * plain read and write would let both through.
+ *
+ * Keys are the series' uid, never the id: synthetic ids reshuffle when an override
+ * changes, and a reminder fired again inside its ten minutes.
+ *
+ * The store path notes what it rang under its own prefix. That keeps the other path off
+ * it at a handover, and doesn't count against the store's own summary later: a reminder
+ * it rang and snoozed can still be missed.
+ */
+
+const STORAGE_KEY = 'calendar-alert-claims';
+const LOCK = 'bulwark-calendar-alert-claim';
+const KEEP_MS = 24 * 60 * 60 * 1000;
+const STORE_PREFIX = 'store:';
+/** A tab out of view waits, so the one in view is who shows the reminder. */
+export const HIDDEN_GRACE_MS = 2000;
+
+export function claimKey(account: string | null | undefined, uid: string, alertId: string, fireTimeMs: number): string {
+ return `${account ?? ''}:${uid}:${alertId}:${fireTimeMs}`;
+}
+
+function readClaims(): Record {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as unknown;
+ return parsed && typeof parsed === 'object' ? parsed as Record : {};
+ } catch {
+ return {};
+ }
+}
+
+function writeClaim(claims: Record, key: string, fireTimeMs: number): void {
+ const now = Date.now();
+ const kept = Object.fromEntries(Object.entries(claims).filter(([, fire]) => now - fire < KEEP_MS));
+ kept[key] = fireTimeMs;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(kept));
+ } catch {
+ // Without storage each tab is on its own, as it always was.
+ }
+}
+
+/** True for the first to ask. */
+export function claimNow(key: string, fireTimeMs: number): boolean {
+ const claims = readClaims();
+ if (key in claims || `${STORE_PREFIX}${key}` in claims) return false;
+ writeClaim(claims, key, fireTimeMs);
+ return true;
+}
+
+/** The path without a store rang this one. */
+export function firedWithoutStore(key: string): boolean {
+ return key in readClaims();
+}
+
+/** The store path is ringing this. False when the other path already did. */
+export function noteStoreFired(key: string, fireTimeMs: number): boolean {
+ const claims = readClaims();
+ if (key in claims) return false;
+ writeClaim(claims, `${STORE_PREFIX}${key}`, fireTimeMs);
+ return true;
+}
+
+/** Runs `won` in the one tab that gets the reminder. At once, when nothing makes it wait. */
+export function claimAlert(key: string, fireTimeMs: number, won: () => void): void {
+ const direct = () => {
+ if (claimNow(key, fireTimeMs)) won();
+ };
+ const attempt = () => {
+ const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
+ if (!locks) {
+ direct();
+ return;
+ }
+ void locks.request(LOCK, () => claimNow(key, fireTimeMs)).then((claimed) => {
+ if (claimed) won();
+ }, direct);
+ };
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') setTimeout(attempt, HIDDEN_GRACE_MS);
+ else attempt();
+}
+
+/** A sign-out: the next person starts with a clean slate. */
+export function clearClaims(): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // Nothing was kept, then.
+ }
+}
+
+export const CLAIMS_STORAGE_KEY = STORAGE_KEY;
diff --git a/lib/calendar-reminders/runner.ts b/lib/calendar-reminders/runner.ts
new file mode 100644
index 000000000..876f85ade
--- /dev/null
+++ b/lib/calendar-reminders/runner.ts
@@ -0,0 +1,194 @@
+/**
+ * Asks the local store what's due, at the moment it's due. Runs in the page: in the
+ * desktop app that's the main window, which keeps its timers while hidden in the tray,
+ * and the store's worker answers with no network. Every tab or window may run one: the
+ * store hands each alert to whichever asks first, so a tab out of view waits a moment
+ * before every check, whatever set it off.
+ *
+ * Between checks it sleeps until the store's next fire time, and looks in every few
+ * minutes in case something changed with nobody saying so.
+ */
+
+import type { StoreBackend } from '@/lib/store/client/api';
+import type { ReminderAlert, TickParams, TickResult } from '@/lib/store/reminders/ledger';
+import { HIDDEN_GRACE_MS } from './claims';
+
+const REFRESH_MS = 5 * 60_000;
+/** Timers stand still while a machine sleeps. The clock doesn't. */
+const CLOCK_WATCH_MS = 60_000;
+const CLOCK_JUMP_MS = 2 * CLOCK_WATCH_MS;
+/**
+ * After a start or a wake, how long a summary waits for the first sync, so it leaves out
+ * what was deleted elsewhere in the meantime. Reminders that are due don't wait.
+ */
+export const SUMMARY_WAIT_MS = 20_000;
+
+export interface ReminderRunnerEnv {
+ /** The local accounts with a store right now. */
+ accounts(): string[];
+ store(): Pick;
+ params(): TickParams;
+ /** Out of view: another tab in view should get to go first. */
+ hidden(): boolean;
+ offline(): boolean;
+ /** No push channel to say the calendar changed, so the store has to go and look. */
+ needsPolling(account: string): boolean;
+ /** Runs in the one tab that got these. */
+ onDue(account: string, alerts: ReminderAlert[]): void;
+ /** There's a summary to give. Resolves when that's been tried. */
+ onMissed(account: string): Promise;
+ /** Whether this account's reminders come from the store now, or the app's old path keeps them. */
+ onSource(account: string, driven: boolean): void;
+}
+
+export interface ReminderRunner {
+ start(): void;
+ stop(): void;
+ /** Check now: the calendar changed, a snooze was set. */
+ poke(): void;
+ /** Back from sleep, or back online. */
+ wake(): void;
+ /** The store finished a sync for this account. */
+ synced(account: string): void;
+}
+
+export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner {
+ let timer: ReturnType | null = null;
+ let timerAt = 0;
+ let clockWatch: ReturnType | null = null;
+ let lastWatch = 0;
+ let running = false;
+ let again = false;
+ let stopped = true;
+ let summaryAt = 0;
+ const presenting = new Set();
+ const known = new Set();
+ const lastPoll = new Map();
+
+ const schedule = (at: number) => {
+ if (timer) clearTimeout(timer);
+ timerAt = at + (env.hidden() ? HIDDEN_GRACE_MS : 0);
+ timer = setTimeout(() => void run(), Math.max(timerAt - Date.now(), 0));
+ };
+
+ /** At once in a tab in view. Out of view, after the grace, however often it's asked. */
+ const request = () => {
+ if (stopped) return;
+ if (!env.hidden()) {
+ void run();
+ return;
+ }
+ if (timer && timerAt <= Date.now() + HIDDEN_GRACE_MS) return;
+ schedule(Date.now());
+ };
+
+ const poll = (account: string) => {
+ const now = Date.now();
+ if (!lastPoll.has(account)) lastPoll.set(account, now);
+ if (now - lastPoll.get(account)! < REFRESH_MS || env.offline() || !env.needsPolling(account)) return;
+ lastPoll.set(account, now);
+ void env.store().sync(account).catch(() => {});
+ };
+
+ const check = async (account: string): Promise => {
+ let result: TickResult;
+ try {
+ result = await env.store().area(account, 'reminders.tick', env.params());
+ } catch {
+ // The store is out of reach: the old path takes these over until it's back.
+ if (!stopped) env.onSource(account, false);
+ return null;
+ }
+ // Stopped while asking: the store has given these to this page and nobody will show them.
+ if (stopped) {
+ if (result.due.length > 0) {
+ await env.store().area(account, 'reminders.hold', { keys: result.due.map((alert) => alert.key) }).catch(() => {});
+ }
+ return null;
+ }
+ env.onSource(account, result.ready);
+ if (result.due.length > 0) env.onDue(account, result.due);
+ if (result.missed === 0) return result.nextAt;
+ const wait = env.offline() ? 0 : summaryAt - Date.now();
+ if (wait > 0) return result.nextAt === null ? summaryAt : Math.min(result.nextAt, summaryAt);
+ if (!presenting.has(account)) {
+ presenting.add(account);
+ void env.onMissed(account)
+ .catch(() => {})
+ .finally(() => presenting.delete(account));
+ }
+ return result.nextAt;
+ };
+
+ const run = async () => {
+ if (stopped) return;
+ if (running) {
+ again = true;
+ return;
+ }
+ running = true;
+ let next = Date.now() + REFRESH_MS;
+ try {
+ const accounts = env.accounts();
+ for (const gone of [...known]) {
+ if (!accounts.includes(gone)) {
+ known.delete(gone);
+ env.onSource(gone, false);
+ }
+ }
+ for (const account of accounts) {
+ known.add(account);
+ poll(account);
+ const at = await check(account);
+ if (at !== null) next = Math.min(next, at);
+ }
+ } catch (error) {
+ // A plugin or a toast threw on the way out. The store has the rest for the next check.
+ console.error('A reminder check failed:', error);
+ } finally {
+ running = false;
+ }
+ if (stopped) return;
+ if (again) {
+ again = false;
+ request();
+ return;
+ }
+ schedule(next);
+ };
+
+ return {
+ start() {
+ stopped = false;
+ summaryAt = Date.now() + SUMMARY_WAIT_MS;
+ lastWatch = Date.now();
+ clockWatch = setInterval(() => {
+ const now = Date.now();
+ const jumped = now - lastWatch > CLOCK_JUMP_MS;
+ lastWatch = now;
+ if (jumped) this.wake();
+ }, CLOCK_WATCH_MS);
+ request();
+ },
+ stop() {
+ stopped = true;
+ if (timer) clearTimeout(timer);
+ if (clockWatch) clearInterval(clockWatch);
+ timer = null;
+ clockWatch = null;
+ for (const account of known) env.onSource(account, false);
+ known.clear();
+ },
+ poke() {
+ request();
+ },
+ wake() {
+ summaryAt = Date.now() + SUMMARY_WAIT_MS;
+ request();
+ },
+ synced() {
+ summaryAt = 0;
+ request();
+ },
+ };
+}
diff --git a/lib/calendar-reminders/state.ts b/lib/calendar-reminders/state.ts
new file mode 100644
index 000000000..109df52ee
--- /dev/null
+++ b/lib/calendar-reminders/state.ts
@@ -0,0 +1,55 @@
+/**
+ * What the reminder code shares across the page, kept free of the rest of the app so
+ * useCalendarAlerts can read it without pulling in the local store.
+ */
+
+import { create } from 'zustand';
+import type { ReminderAlert } from '@/lib/store/reminders/ledger';
+
+interface ReminderSourceState {
+ /** Local accounts whose reminders come from the store: useCalendarAlerts leaves those alone. */
+ driven: Record;
+ setDriven: (account: string, driven: boolean) => void;
+}
+
+export const useReminderSource = create()((set) => ({
+ driven: {},
+ setDriven: (account, driven) => set((state) => {
+ if (!!state.driven[account] === driven) return state;
+ const next = { ...state.driven };
+ if (driven) next[account] = true;
+ else delete next[account];
+ return { driven: next };
+ }),
+}));
+
+interface MissedRemindersState {
+ account: string | null;
+ alerts: ReminderAlert[];
+ show: (account: string, alerts: ReminderAlert[]) => void;
+ close: () => void;
+}
+
+/** The summary dialog's contents. Empty: it's closed. */
+export const useMissedReminders = create()((set) => ({
+ account: null,
+ alerts: [],
+ show: (account, alerts) => set({ account, alerts }),
+ close: () => set({ account: null, alerts: [] }),
+}));
+
+let poke: (() => void) | null = null;
+
+/** The running check, so something done from a toast takes effect at once. */
+export function setReminderPoke(next: (() => void) | null): void {
+ poke = next;
+}
+
+export function pokeReminders(): void {
+ poke?.();
+}
+
+/** The zone that reads a floating event: the setting's, or the system's on `auto`. */
+export function reminderTimeZone(setting: string | undefined): string {
+ return setting && setting !== 'auto' ? setting : Intl.DateTimeFormat().resolvedOptions().timeZone;
+}
diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts
new file mode 100644
index 000000000..2427f7c19
--- /dev/null
+++ b/lib/calendar-reminders/system-notification.ts
@@ -0,0 +1,73 @@
+/**
+ * A browser's system notification for a reminder, where the user has already granted the
+ * permission. This never asks for it. Through the service worker where there is one, since
+ * Chrome on Android refuses `new Notification`. The tag keeps one per reminder, whichever
+ * tab asks.
+ */
+
+export interface SystemReminder {
+ key: string;
+ title: string;
+ body: string;
+ /** Do not disturb: on screen, but quiet. */
+ silent: boolean;
+ /** Says nothing about the event: the app is locked, or the organisation says so. */
+ hidden?: { title: string };
+}
+
+function tagOf(key: string): string {
+ return `calendar-reminder:${key}`;
+}
+
+function permitted(): boolean {
+ return typeof Notification !== 'undefined' && Notification.permission === 'granted';
+}
+
+async function registration(): Promise {
+ try {
+ return await navigator.serviceWorker?.getRegistration();
+ } catch {
+ return undefined;
+ }
+}
+
+/** In front of the user the in-app toast says it already. */
+export function wantsSystemNotification(): boolean {
+ if (!permitted() || typeof document === 'undefined') return false;
+ return document.visibilityState === 'hidden' || !document.hasFocus();
+}
+
+export async function showSystemReminder(reminder: SystemReminder, openUrl: string): Promise {
+ if (!wantsSystemNotification()) return;
+ const title = reminder.hidden ? reminder.hidden.title : reminder.title;
+ const options = { body: reminder.hidden ? '' : reminder.body, tag: tagOf(reminder.key), silent: reminder.silent };
+ try {
+ const worker = await registration();
+ if (worker) {
+ await worker.showNotification(title, { ...options, data: { kind: 'calendar-reminder', url: openUrl } });
+ return;
+ }
+ const shown = new Notification(title, options);
+ shown.onclick = () => {
+ window.focus();
+ window.location.href = openUrl;
+ shown.close();
+ };
+ } catch (error) {
+ console.warn('A system notification could not be shown:', error);
+ }
+}
+
+/** Dealt with elsewhere: it shouldn't stay in the notification list. */
+export async function closeSystemReminders(keys: readonly string[]): Promise {
+ if (!permitted() || keys.length === 0) return;
+ const worker = await registration();
+ if (!worker) return;
+ try {
+ for (const key of keys) {
+ for (const shown of await worker.getNotifications({ tag: tagOf(key) })) shown.close();
+ }
+ } catch {
+ // Nothing that matters is left behind.
+ }
+}
diff --git a/lib/desktop/__tests__/desktop-telemetry.test.ts b/lib/desktop/__tests__/desktop-telemetry.test.ts
new file mode 100644
index 000000000..1d7b0bc21
--- /dev/null
+++ b/lib/desktop/__tests__/desktop-telemetry.test.ts
@@ -0,0 +1,160 @@
+// @vitest-environment node
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { describe, expect, it } from 'vitest';
+import { loadCollectorValidator } from '@/lib/__tests__/collector-validator';
+import {
+ DESKTOP_CATCH_UP_MAX_MS,
+ DESKTOP_CATCH_UP_MIN_MS,
+ DESKTOP_HEARTBEAT_INTERVAL_MS,
+ buildDesktopErrorBatch,
+ buildDesktopPing,
+ consentInForce,
+ desktopBuildTag,
+ desktopCatchUpMs,
+ desktopChannel,
+ desktopOsFamily,
+ forcedOffByPolicy,
+ heartbeatDelayMs,
+} from '../desktop-telemetry';
+
+const collector = await loadCollectorValidator();
+
+const NOT_SET = { value: true, locked: false };
+const FORCED_OFF = { value: false, locked: true };
+const FORCED_ON = { value: true, locked: true };
+const RECOMMENDED_OFF = { value: false, locked: false };
+
+describe('consent', () => {
+ it.each([
+ [undefined, NOT_SET, false],
+ [null, NOT_SET, false],
+ [false, NOT_SET, false],
+ [true, NOT_SET, true],
+ // A policy forcing it off beats the user's yes.
+ [true, FORCED_OFF, false],
+ // A policy allowing it doesn't answer for the user.
+ [undefined, FORCED_ON, false],
+ [true, FORCED_ON, true],
+ // Only forced counts as forced.
+ [true, RECOMMENDED_OFF, true],
+ [undefined, RECOMMENDED_OFF, false],
+ ])('choice %s under %j is %s', (choice, policy, expected) => {
+ expect(consentInForce(choice, policy)).toBe(expected);
+ });
+
+ it('knows forced off from the rest', () => {
+ expect(forcedOffByPolicy(FORCED_OFF)).toBe(true);
+ expect(forcedOffByPolicy(RECOMMENDED_OFF)).toBe(false);
+ expect(forcedOffByPolicy(FORCED_ON)).toBe(false);
+ });
+});
+
+describe('when a heartbeat is due', () => {
+ const now = Date.parse('2026-11-03T12:00:00Z');
+ const CATCH_UP = 123_000;
+
+ it('soon, with none sent yet or the last one long ago', () => {
+ expect(heartbeatDelayMs(undefined, now, CATCH_UP)).toBe(CATCH_UP);
+ expect(heartbeatDelayMs(null, now, CATCH_UP)).toBe(CATCH_UP);
+ expect(heartbeatDelayMs(now - 3 * DESKTOP_HEARTBEAT_INTERVAL_MS, now, CATCH_UP)).toBe(CATCH_UP);
+ });
+
+ it('a day after the last, and not sooner', () => {
+ expect(heartbeatDelayMs(now - 3_600_000, now, CATCH_UP)).toBe(DESKTOP_HEARTBEAT_INTERVAL_MS - 3_600_000);
+ });
+
+ it('soon again if the clock went back', () => {
+ expect(heartbeatDelayMs(now + 3_600_000, now, CATCH_UP)).toBe(CATCH_UP);
+ });
+
+ it('soon means somewhere from 30 s to 10 min, so a start time can\'t be read from it', () => {
+ expect(desktopCatchUpMs(() => 0)).toBe(30_000);
+ expect(desktopCatchUpMs(() => 0.5)).toBe(315_000);
+ expect(desktopCatchUpMs(() => 0.999999)).toBeLessThanOrEqual(600_000);
+ expect(desktopCatchUpMs(() => 0.999999)).toBeGreaterThan(599_000);
+ expect(DESKTOP_CATCH_UP_MIN_MS).toBe(30_000);
+ expect(DESKTOP_CATCH_UP_MAX_MS).toBe(600_000);
+ const seen = new Set(Array.from({ length: 50 }, () => desktopCatchUpMs()));
+ expect(seen.size).toBeGreaterThan(1);
+ for (const value of seen) {
+ expect(value).toBeGreaterThanOrEqual(30_000);
+ expect(value).toBeLessThanOrEqual(600_000);
+ }
+ expect(heartbeatDelayMs(null, now)).toBeLessThanOrEqual(600_000);
+ });
+});
+
+describe('the shell\'s own heartbeat', () => {
+ const ping = () => buildDesktopPing({
+ instanceId: 'f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b',
+ appVersion: '0.3.0-beta.1',
+ nodeVersion: '22.11.0',
+ osFamily: 'windows',
+ mode: 'instance',
+ channel: 'beta',
+ now: Date.parse('2026-11-03T12:00:00Z'),
+ });
+
+ it('claims nothing that is the instance\'s to report', () => {
+ const body = ping();
+ expect(Object.values(body.features)).toEqual([false, false, false, false, false, false]);
+ expect(body.counts).toEqual({ accounts: '0', accounts_active_7d: '0', extensions_installed: 0, themes_installed: 0 });
+ expect(body.stalwart_version).toBeNull();
+ expect(body.instance_id).toBe('f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b');
+ });
+
+ it.skipIf(!collector)('is what the real collector accepts, as schema 1 and as schema 2', () => {
+ const clean = collector!.validate(ping())!;
+ expect(clean).toMatchObject({ schema: '1', build: 'desktop.instance.beta', version: '0.3.0-beta.1', os_family: 'windows', node_version: '22.11.0' });
+ expect(clean.platform).toBe('desktop');
+ // Any extra top-level field would make schema 2 refuse the whole heartbeat.
+ expect(collector!.validate({ ...ping(), schema: '2', errors: [] })).not.toBeNull();
+ });
+
+ it.skipIf(!collector)('main\'s error batch is what the real collector accepts', () => {
+ const now = Date.now();
+ const batch = buildDesktopErrorBatch('f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b', now, [
+ { name: 'TypeError', fingerprint: '0123456789abcdef', version: '0.3.0+desktop', count: 7 },
+ ]);
+ const clean = collector!.validateErrorBatch(batch)!;
+ expect(clean).not.toBeNull();
+ expect(collector!.validateErrorBatch(buildDesktopErrorBatch('f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b', now, [
+ { name: 'TypeError', fingerprint: '0123456789abcdef', version: '0.3.0-beta.1+desktop', count: 1 },
+ ]))).not.toBeNull();
+ expect(clean.errors).toEqual([expect.objectContaining({ name: 'TypeError', count: '6-10', version: '0.3.0+desktop', days: 1, frames: [] })]);
+ });
+
+ it('fits the collector\'s pattern for build on every mode and channel', () => {
+ for (const mode of ['standalone', 'instance'] as const) {
+ for (const channel of ['stable', 'beta'] as const) {
+ expect(desktopBuildTag(mode, channel)).toMatch(/^[\w.+-]{1,64}$/);
+ }
+ }
+ });
+
+ it('names the channel: forced, then the user, then the version', () => {
+ expect(desktopChannel({ value: 'stable', locked: true }, 'beta', '1.0.0-beta.1')).toBe('stable');
+ expect(desktopChannel({ value: 'stable', locked: false }, 'beta', '1.0.0')).toBe('beta');
+ expect(desktopChannel({ value: 'stable', locked: false }, undefined, '1.0.0-beta.1')).toBe('beta');
+ expect(desktopChannel({ value: 'stable', locked: false }, 'nonsense', '1.0.0')).toBe('stable');
+ });
+
+ it('names the OS the way the collector does', () => {
+ expect(desktopOsFamily('win32')).toBe('windows');
+ expect(desktopOsFamily('darwin')).toBe('darwin');
+ expect(desktopOsFamily('linux')).toBe('linux');
+ expect(desktopOsFamily('freebsd')).toBe('unknown');
+ });
+
+ it('holds no host name, so only one place has the collector', () => {
+ const source = readFileSync(path.join(__dirname, '..', 'desktop-telemetry.ts'), 'utf8');
+ expect(source).not.toContain('bulwarkmail.org');
+ });
+
+ it('the policy says which shell file acts on the two keys', async () => {
+ const { POLICY_CONSUMERS } = await import('../policy-schema');
+ expect(POLICY_CONSUMERS.TelemetryAllowed).toEqual({ repo: 'shell', file: 'src/main/features/privacy.ts' });
+ expect(POLICY_CONSUMERS.CrashReportsAllowed).toEqual({ repo: 'shell', file: 'src/main/features/privacy.ts' });
+ });
+});
diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts
new file mode 100644
index 000000000..d3f717f64
--- /dev/null
+++ b/lib/desktop/__tests__/os-commands.test.ts
@@ -0,0 +1,273 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BulwarkDesktopBridge, DesktopOsCommand } from '../types';
+import type { NotificationActionRequest, NotificationActionStatus } from '@/lib/pwa-notify/page-actions';
+
+/** What the OS asks for, carried out by the parts the app already has. */
+
+const runNotificationAction = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/pwa-notify/page-actions', () => ({ runNotificationAction }));
+
+const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }];
+let authenticated = true;
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: {
+ getState: () => ({ isAuthenticated: authenticated }),
+ subscribe: () => () => {},
+ },
+}));
+
+const session = vi.hoisted(() => ({
+ savePendingMailto: vi.fn(),
+ notifyPendingMailto: vi.fn(),
+ savePendingWebcal: vi.fn(),
+ notifyPendingWebcal: vi.fn(),
+}));
+vi.mock('@/lib/protocol-handlers/session', () => session);
+
+async function fingerprint() {
+ const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint');
+ return loginFingerprint('https://one.example', 'alice@example.org');
+}
+
+const leaveFor = vi.fn();
+const push = vi.fn();
+const notifyFailed = vi.fn();
+const notifyReplyLost = vi.fn();
+const env = { leaveFor, push, notifyFailed, notifyReplyLost };
+const focus = vi.fn();
+
+async function run(command: DesktopOsCommand) {
+ const { handleOsCommand } = await import('../os-commands');
+ await handleOsCommand(command, env);
+}
+
+const text = (value: string) => new TextEncoder().encode(value);
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ authenticated = true;
+ window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus } } as unknown as BulwarkDesktopBridge;
+ const { resetMailLinks } = await import('../mail-links');
+ resetMailLinks();
+ const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
+ clearHandoffs();
+ for (const kind of ['compose', 'eml', 'calendar', 'contacts'] as const) takeHandoff(kind);
+});
+
+afterEach(() => {
+ window.bulwarkDesktop = undefined;
+});
+
+describe('launches', () => {
+ it('New message goes through the launch handoff, with no question asked', async () => {
+ const { subscribeHandoff } = await import('@/lib/launch-handoff');
+ const fired = vi.fn();
+ const stop = subscribeHandoff('compose', fired);
+ await run({ type: 'new-message' });
+ expect(fired).toHaveBeenCalled();
+ stop();
+ });
+
+ it('a mailto goes through the protocol handler to the mail page', async () => {
+ await run({ type: 'mailto', url: 'mailto:bob@example.org?subject=Hi&body=Text' });
+ expect(session.savePendingMailto).toHaveBeenCalledTimes(1);
+ expect(session.savePendingMailto.mock.calls[0][0]).toMatchObject({ to: ['bob@example.org'], subject: 'Hi' });
+ expect(session.notifyPendingMailto).toHaveBeenCalled();
+ expect(push).toHaveBeenCalledWith('/');
+ });
+
+ it('a mailto that is not one does nothing', async () => {
+ await run({ type: 'mailto', url: 'mailto:' + '%'.repeat(3) });
+ expect(push).not.toHaveBeenCalledWith('/calendar');
+ });
+
+ it('a webcal goes to the calendar', async () => {
+ await run({ type: 'webcal', url: 'webcal://example.org/cal.ics' });
+ expect(session.savePendingWebcal).toHaveBeenCalledTimes(1);
+ expect(session.notifyPendingWebcal).toHaveBeenCalled();
+ expect(push).toHaveBeenCalledWith('/calendar');
+ });
+
+ it('a double-clicked .eml, .ics and .vcf each reach their handler', async () => {
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ await run({
+ type: 'files',
+ reason: 'open',
+ failed: [],
+ files: [
+ { name: 'letter.eml', data: text('Subject: x') },
+ { name: 'party.ics', data: text('BEGIN:VCALENDAR') },
+ { name: 'bob.vcf', data: text('BEGIN:VCARD') },
+ ],
+ });
+ expect(await takeHandoff('eml')?.files[0].text()).toBe('Subject: x');
+ expect(takeHandoff('calendar')?.files[0].name).toBe('party.ics');
+ expect(takeHandoff('contacts')?.files[0].name).toBe('bob.vcf');
+ expect(notifyFailed).not.toHaveBeenCalled();
+ });
+
+ it('a file dropped on the icon becomes an attachment on a new draft', async () => {
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'report.pdf', data: text('pdf') }] });
+ const draft = takeHandoff('compose');
+ expect(draft?.files.map((file) => file.name)).toEqual(['report.pdf']);
+ });
+
+ it('says so when a file could not be read', async () => {
+ await run({ type: 'files', reason: 'open', failed: ['gone.eml'], files: [] });
+ expect(notifyFailed).toHaveBeenCalledWith(['gone.eml']);
+ });
+});
+
+describe('going somewhere', () => {
+ it('Calendar and Contacts leave through the save-first path', async () => {
+ await run({ type: 'navigate', target: 'calendar' });
+ expect(leaveFor).toHaveBeenCalledWith('/calendar');
+ });
+
+ it('Inbox opens the folder on the mail page when it is mounted', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'navigate', target: 'inbox' });
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined);
+ expect(leaveFor).not.toHaveBeenCalled();
+ stop();
+ });
+
+ it('with the mail page not mounted the link waits for it, and the app goes there', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ await run({ type: 'open-message', emailId: 'e1', accountId: 'acc' });
+ expect(leaveFor).toHaveBeenCalledWith('/');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ expect(opener).toHaveBeenCalledWith({ kind: 'message', id: 'e1', ownerAccountId: 'acc' }, undefined);
+ stop();
+ });
+});
+
+describe('a toast\'s buttons', () => {
+ const context = async (over: object = {}) => JSON.stringify({ emailId: 'e1', threadId: 't1', accountId: 'acc', slot: 3, login: await fingerprint(), ...over });
+ const finishWith = (status: NotificationActionStatus) => {
+ runNotificationAction.mockImplementation(async (_request: NotificationActionRequest, answer: (s: NotificationActionStatus) => void) => {
+ answer('accepted');
+ answer(status);
+ });
+ };
+
+ it.each(['archive', 'mark-read', 'delete'] as const)('%s runs through the page\'s notification action and stays out of sight', async (action) => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ finishWith('done');
+ await run({ type: 'notification', action, context: await context() });
+ expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action, emailId: 'e1', accountId: 'acc', slot: 3, login: await fingerprint() });
+ expect(opener).not.toHaveBeenCalled();
+ expect(focus).not.toHaveBeenCalled();
+ stop();
+ });
+
+ it('a button that can\'t be carried out opens the message instead', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ finishWith('failed');
+ await run({ type: 'notification', action: 'archive', context: await context() });
+ expect(focus).toHaveBeenCalled();
+ expect(opener).toHaveBeenCalledWith(expect.objectContaining({ kind: 'message', id: 'e1', slot: 3 }), undefined);
+ stop();
+ });
+
+ it('a reply opens the composer with the typed text, and is not sent', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'reply', context: await context(), reply: 'On my way' });
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(opener).toHaveBeenCalledWith(
+ { kind: 'message', id: 'e1', slot: 3, ownerAccountId: 'acc', reply: true },
+ { replyText: 'On my way' },
+ );
+ stop();
+ });
+
+ it('behind the app lock an old toast acts on nothing, and the window comes up', async () => {
+ const { setAppLockCheck } = await import('../app-lock-state');
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ setAppLockCheck(() => true);
+ for (const action of ['delete', 'archive', 'mark-read', 'open', 'reply']) {
+ await run({ type: 'notification', action, context: await context(), reply: 'x' });
+ }
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(opener).not.toHaveBeenCalled();
+ expect(focus).toHaveBeenCalled();
+ setAppLockCheck(null);
+ stop();
+ });
+
+ it('typed text that has nowhere to go says so', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const stop = setMailLinkOpener(vi.fn());
+ await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }), reply: 'On my way' });
+ expect(notifyReplyLost).toHaveBeenCalled();
+ notifyReplyLost.mockClear();
+ await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }) });
+ expect(notifyReplyLost).not.toHaveBeenCalled();
+ stop();
+ });
+
+ it('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'open', context: await context({ login: 'someone-else' }) });
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined);
+ stop();
+ });
+
+ it('a click on a summary toast opens the Inbox of that login', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'open', context: await context({ emailId: undefined }) });
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox', slot: 3 }, undefined);
+ stop();
+ });
+
+ it('junk in the context does no harm', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'archive', context: '{not json' });
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined);
+ stop();
+ });
+});
+
+describe('listening', () => {
+ it('does nothing in a browser', async () => {
+ window.bulwarkDesktop = undefined;
+ const { startOsCommands } = await import('../os-commands');
+ expect(() => startOsCommands(env)()).not.toThrow();
+ });
+
+ it('subscribes, and carries out what arrives', async () => {
+ let listener: ((command: DesktopOsCommand) => void) | null = null;
+ window.bulwarkDesktop = {
+ version: 1,
+ platform: 'win32',
+ os: { onCommand: (l: (command: DesktopOsCommand) => void) => { listener = l; return () => { listener = null; }; } },
+ } as unknown as BulwarkDesktopBridge;
+ const { startOsCommands } = await import('../os-commands');
+ const stop = startOsCommands(env);
+ listener!({ type: 'navigate', target: 'contacts' });
+ await Promise.resolve();
+ expect(leaveFor).toHaveBeenCalledWith('/contacts');
+ stop();
+ expect(listener).toBeNull();
+ });
+});
diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts
new file mode 100644
index 000000000..da28971f6
--- /dev/null
+++ b/lib/desktop/__tests__/os-mail.test.ts
@@ -0,0 +1,371 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BulwarkDesktopBridge, DesktopOsNotification } from '../types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+/** New mail to a native notification: which messages count, what the toast may say. */
+
+const playNotificationSound = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/notification-sound', () => ({ playNotificationSound }));
+
+let pushWithoutContent = false;
+vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent }) }));
+
+const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org', label: 'Work', email: 'alice@example.org' }];
+let client: IJMAPClient;
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: {
+ getState: () => ({
+ activeAccountId: 'alice@one',
+ client,
+ getClientForAccount: () => client,
+ }),
+ },
+}));
+
+const INBOX = { id: 'in', role: 'inbox', name: 'Inbox' } as unknown as Mailbox;
+const SENT = { id: 'out', role: 'sent', name: 'Sent' } as unknown as Mailbox;
+const NEWS = { id: 'news', name: 'News' } as unknown as Mailbox;
+const JUNK = { id: 'bad', role: 'junk', name: 'Junk' } as unknown as Mailbox;
+const MAILBOXES = [INBOX, SENT, NEWS, JUNK];
+
+function email(id: string, over: Partial = {}): Email {
+ return {
+ id,
+ threadId: `t-${id}`,
+ subject: `Secret ${id}`,
+ preview: 'preview text',
+ from: [{ name: 'Alice Sender', email: 'sender@example.org' }],
+ mailboxIds: { in: true },
+ keywords: {},
+ receivedAt: new Date().toISOString(),
+ ...over,
+ } as unknown as Email;
+}
+
+function fakeClient(mail: Record, changes: { created: string[]; newState: string }[] = []) {
+ const queue = [...changes];
+ return {
+ getAccountId: () => 'acc',
+ getAllMailboxes: vi.fn(async () => MAILBOXES),
+ getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0, state: 'seeded' })),
+ getEmailChanges: vi.fn(async (since: string) => {
+ const next = queue.shift();
+ return next ? { oldState: since, newState: next.newState, created: next.created, updated: [], destroyed: [], hasMoreChanges: false } : null;
+ }),
+ getSomeEmails: vi.fn(async (ids: string[]) => ids.flatMap((id) => mail[id] ?? [])),
+ } as unknown as IJMAPClient;
+}
+
+const labels = {
+ newMail: 'new_mail',
+ newMailCount: (count: number) => `count:${count}`,
+ noSubject: 'no_subject',
+ archive: 'archive',
+ markRead: 'mark_read',
+ delete: 'delete',
+ reply: 'reply',
+ replyPlaceholder: 'placeholder',
+};
+
+function setBridge(bridge: Partial | undefined) {
+ window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
+}
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ pushWithoutContent = false;
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({
+ emailNotificationsEnabled: true,
+ emailNotificationSound: true,
+ doNotDisturb: false,
+ pushNotifyInboxOnly: true,
+ notificationMutes: { accounts: [], mailboxes: {} },
+ notificationSoundChoice: 'involved',
+ });
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(null);
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ usePolicyStore.setState({ loaded: true });
+});
+
+afterEach(() => {
+ setBridge(undefined);
+ vi.useRealTimers();
+});
+
+describe('which new messages notify', () => {
+ it('a new unread message in the Inbox', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)?.id).toBe('in');
+ });
+
+ it('nothing already read, a draft, or old mail coming back from an import', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ expect(notifyingMailbox(email('a', { keywords: { $seen: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ expect(notifyingMailbox(email('a', { keywords: { $draft: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ const old = new Date(Date.now() - 3 * 86400_000).toISOString();
+ expect(notifyingMailbox(email('a', { receivedAt: old }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ });
+
+ it('with Inbox only on, mail filed elsewhere stays silent; off, it notifies, but never Junk or Sent', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ const filed = email('a', { mailboxIds: { news: true } });
+ expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)).toBeNull();
+ useSettingsStore.setState({ pushNotifyInboxOnly: false });
+ expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)?.id).toBe('news');
+ expect(notifyingMailbox(email('b', { mailboxIds: { bad: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ expect(notifyingMailbox(email('c', { mailboxIds: { out: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ });
+
+ it('a muted folder or account stays silent', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({ notificationMutes: { accounts: [], mailboxes: { 'acc/in': true } } });
+ expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull();
+ useSettingsStore.setState({ notificationMutes: { accounts: ['acc'], mailboxes: {} } });
+ expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull();
+ });
+});
+
+describe('the watch', () => {
+ const flush = async () => {
+ for (let i = 0; i < 20; i += 1) await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ };
+
+ it('resolves a push against its baseline, whatever page is open', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ const seen: string[] = [];
+ const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
+ watch.seed(fake);
+ await flush();
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'background');
+ await flush();
+ expect(fake.getEmailChanges).toHaveBeenCalledWith('seeded', 'acc', 200);
+ expect(seen).toEqual(['a']);
+ watch.stop();
+ });
+
+ it('tells a message once when the store and the push both report it', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ const seen: string[] = [];
+ const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
+ watch.seed(fake);
+ await flush();
+ watch.noteCreated(fake, 'acc', ['a']);
+ await flush();
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
+ await flush();
+ expect(seen).toEqual(['a']);
+ watch.stop();
+ });
+
+ it('a message the store has not got yet is not written off: its created event still tells it', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const held: Record = {};
+ const fake = fakeClient(held, [{ created: ['a'], newState: 's2' }]);
+ const seen: string[] = [];
+ const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
+ watch.seed(fake);
+ await flush();
+ // The push got here first. The device answers, and it doesn't have the message.
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
+ await flush();
+ expect(seen).toEqual([]);
+ // The store has synced it now.
+ held.a = email('a');
+ watch.noteCreated(fake, 'acc', ['a']);
+ await flush();
+ expect(seen).toEqual(['a']);
+ watch.stop();
+ });
+
+ it('without a baseline the first push only sets one: old mail is not news', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({}, []);
+ const onMail = vi.fn();
+ const watch = startNewMailWatch(onMail);
+ emitPushStateChange({ changed: { acc: { Email: 's1' } } } as never, fake, 'active');
+ await flush();
+ expect(fake.getEmailChanges).not.toHaveBeenCalled();
+ expect(onMail).not.toHaveBeenCalled();
+ watch.stop();
+ });
+
+ it('ignores a change after it stopped', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ const onMail = vi.fn();
+ const watch = startNewMailWatch(onMail);
+ watch.seed(fake);
+ await flush();
+ watch.stop();
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
+ await flush();
+ expect(onMail).not.toHaveBeenCalled();
+ });
+});
+
+describe('what the notification says', () => {
+ const one = (id = 'a', over: Partial = {}) => ({ client, accountId: 'acc', email: email(id, over) });
+
+ beforeEach(() => {
+ client = fakeClient({});
+ });
+
+ it('sender, subject, the buttons, and what a button needs after a restart', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint');
+ const notification = await buildMailNotification([one()], labels);
+ expect(notification.title).toBe('Alice Sender');
+ expect(notification.body).toBe('Secret a');
+ expect(notification.actions?.map((a) => a.id)).toEqual(['archive', 'mark-read', 'delete']);
+ expect(notification.reply?.label).toBe('reply');
+ expect(JSON.parse(notification.context)).toEqual({
+ accountId: 'acc',
+ slot: 3,
+ login: await loginFingerprint('https://one.example', 'alice@example.org'),
+ emailId: 'a',
+ threadId: 't-a',
+ });
+ });
+
+ it('no Archive button where the organisation has it off', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const notification = await buildMailNotification([one()], { ...labels, archive: null });
+ expect(notification.actions?.map((a) => a.id)).toEqual(['mark-read', 'delete']);
+ });
+
+ it('while the app is locked: no sender, no subject, no buttons', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(() => true);
+ const notification = await buildMailNotification([one()], labels);
+ const text = JSON.stringify(notification);
+ expect(notification.title).toBe('new_mail');
+ expect(text).not.toContain('Alice Sender');
+ expect(text).not.toContain('Secret');
+ expect(notification.actions).toBeUndefined();
+ expect(notification.reply).toBeUndefined();
+ });
+
+ it('where the organisation keeps content off notifications: the same', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ pushWithoutContent = true;
+ const notification = await buildMailNotification([one()], labels);
+ expect(JSON.stringify(notification)).not.toContain('Secret');
+ expect(JSON.stringify(notification)).not.toContain('Alice Sender');
+ });
+
+ it('before the policy is known: the same', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ usePolicyStore.setState({ loaded: false });
+ const notification = await buildMailNotification([one()], labels);
+ expect(JSON.stringify(notification)).not.toContain('Secret');
+ expect(JSON.stringify(notification)).not.toContain('Alice Sender');
+ });
+
+ it('several at once are told about once, and that one has no message to act on', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels);
+ expect(notification.title).toBe('count:3');
+ expect(notification.actions).toBeUndefined();
+ expect(JSON.parse(notification.context).emailId).toBeUndefined();
+ });
+
+ it('several at once while locked say only how many', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(() => true);
+ const notification = await buildMailNotification([one('a'), one('b')], labels);
+ expect(notification.title).toBe('count:2');
+ expect(notification.body).toBe('');
+ });
+});
+
+describe('from arrival to toast and sound', () => {
+ /** The login's fingerprint comes from crypto.subtle, which no fake timer moves. */
+ const settle = async () => {
+ const end = performance.now() + 80;
+ while (performance.now() < end) await new Promise((resolve) => setImmediate(resolve));
+ };
+
+ async function arrive(over: { result?: { shown: boolean; playSound: boolean } } = {}) {
+ const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const notify = vi.fn(async (_n: DesktopOsNotification) => over.result ?? { shown: true, playSound: true });
+ setBridge({ version: 1, platform: 'win32', os: { notify } as never });
+ client = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const mail = startMailNotifications(labels);
+ mail.seed(client);
+ await vi.advanceTimersByTimeAsync(10);
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, client, 'active');
+ await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
+ await settle();
+ mail.stop();
+ return notify;
+ }
+
+ it('asks main, and plays the picked sound when main says so', async () => {
+ const notify = await arrive();
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(notify.mock.calls[0][0].sound).toBe(true);
+ expect(notify.mock.calls[0][0].attention).toBe(true);
+ expect(playNotificationSound).toHaveBeenCalledWith('involved');
+ });
+
+ it('no sound when main says it is quiet', async () => {
+ const notify = await arrive({ result: { shown: true, playSound: false } });
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(playNotificationSound).not.toHaveBeenCalled();
+ });
+
+ it('nothing at all with mail notifications off or the app\'s Do not disturb on', async () => {
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({ emailNotificationsEnabled: false });
+ expect(await arrive()).not.toHaveBeenCalled();
+ useSettingsStore.setState({ emailNotificationsEnabled: true, doNotDisturb: true });
+ expect(await arrive()).not.toHaveBeenCalled();
+ expect(playNotificationSound).not.toHaveBeenCalled();
+ });
+
+ it("the store's created event reaches a running watch, and nothing else", async () => {
+ 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') });
+ noteStoreCreated(client, 'acc', ['a']);
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const mail = startMailNotifications(labels);
+ noteStoreCreated(client, 'acc', ['a']);
+ await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
+ await settle();
+ expect(notify).toHaveBeenCalledTimes(1);
+ mail.stop();
+ noteStoreCreated(client, 'acc', ['b']);
+ await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
+ await settle();
+ expect(notify).toHaveBeenCalledTimes(1);
+ });
+
+ it('tells main the sound is off when the user has it off', async () => {
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({ emailNotificationSound: false });
+ const notify = await arrive({ result: { shown: true, playSound: false } });
+ expect(notify.mock.calls[0][0].sound).toBe(false);
+ });
+});
diff --git a/lib/desktop/__tests__/os-signout.test.ts b/lib/desktop/__tests__/os-signout.test.ts
new file mode 100644
index 000000000..d640b50b7
--- /dev/null
+++ b/lib/desktop/__tests__/os-signout.test.ts
@@ -0,0 +1,24 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+/** A sign-out leaves nothing of the account on the OS's surfaces. */
+
+vi.setConfig({ testTimeout: 60_000 });
+
+describe('sign-out', () => {
+ afterEach(() => {
+ window.bulwarkDesktop = undefined;
+ });
+
+ it('clears what the desktop app shows outside its windows', async () => {
+ const clearSurfaces = vi.fn();
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never;
+ const { purgeSignedOutData } = await import('@/lib/sign-out-cleanup');
+ purgeSignedOutData();
+ expect(clearSurfaces).toHaveBeenCalled();
+ });
+
+ it('in a browser there is nothing to clear', async () => {
+ const { clearDesktopSurfaces } = await import('../bridge');
+ expect(() => clearDesktopSurfaces()).not.toThrow();
+ });
+});
diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts
new file mode 100644
index 000000000..925e4d024
--- /dev/null
+++ b/lib/desktop/__tests__/os-store.test.ts
@@ -0,0 +1,94 @@
+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. */
+
+// The first import of offline-mode compiles a good part of the app.
+vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
+
+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('a wipe clears what the OS shows, and a browser has nothing to clear', async () => {
+ const { clearDesktopSurfaces } = await import('../bridge');
+ expect(() => clearDesktopSurfaces()).not.toThrow();
+ const clearSurfaces = vi.fn();
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never;
+ const { wipeAccount } = await import('@/lib/offline/offline-mode');
+ const { useOfflineStore } = await import('@/lib/offline/state');
+ // Not the last account, so this isn't the wipe of everything, which clears as well.
+ useOfflineStore.setState({ enabled: ['alice@one', 'bob@two'] });
+ await wipeAccount('alice@one').catch(() => {});
+ expect(clearSurfaces).toHaveBeenCalled();
+ useOfflineStore.setState({ enabled: [] });
+ window.bulwarkDesktop = undefined;
+ });
+
+ 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
new file mode 100644
index 000000000..b6df496bb
--- /dev/null
+++ b/lib/desktop/__tests__/os-window.test.tsx
@@ -0,0 +1,596 @@
+import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types';
+import type { CalendarEvent } from '@/lib/jmap/types';
+
+/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key),
+ useLocale: () => 'en',
+}));
+
+function setBridge(bridge: Partial | undefined, platform: 'win32' | 'darwin' = 'win32') {
+ window.bulwarkDesktop = bridge ? ({ version: 1, platform, os: bridge } as unknown as BulwarkDesktopBridge) : undefined;
+}
+
+afterEach(() => {
+ setBridge(undefined);
+ vi.unstubAllGlobals();
+});
+
+describe('the badge', () => {
+ it('goes to the bridge in the app, and the Badging API is left alone', async () => {
+ const { useAppBadge } = await import('@/hooks/use-app-badge');
+ const setBadge = vi.fn();
+ const setAppBadge = vi.fn(async () => {});
+ Object.defineProperty(navigator, 'setAppBadge', { value: setAppBadge, configurable: true });
+ setBridge({ setBadge });
+ const { rerender } = renderHook(({ count, enabled }) => useAppBadge(count, enabled), { initialProps: { count: 4, enabled: true } });
+ expect(setBadge).toHaveBeenLastCalledWith(4);
+ rerender({ count: 4, enabled: false });
+ expect(setBadge).toHaveBeenLastCalledWith(0);
+ expect(setAppBadge).not.toHaveBeenCalled();
+ });
+
+ it('a pop-out has no say in it', async () => {
+ const { useAppBadge } = await import('@/hooks/use-app-badge');
+ const role = await import('@/lib/popout/window-role');
+ const popout = vi.spyOn(role, 'isPopoutWindow').mockReturnValue(true);
+ const setBadge = vi.fn();
+ setBridge({ setBadge });
+ renderHook(() => useAppBadge(4, true));
+ expect(setBadge).not.toHaveBeenCalled();
+ popout.mockRestore();
+ });
+});
+
+describe('new-mail sound', () => {
+ it('is left to the notification path in the app, not played twice', async () => {
+ const { desktopPlaysMailSound } = await import('../mail-notifications');
+ expect(desktopPlaysMailSound()).toBe(false);
+ setBridge({ notify: vi.fn() as never });
+ expect(desktopPlaysMailSound()).toBe(true);
+ });
+});
+
+describe('the policy keeps refreshing', () => {
+ const fetchPolicy = vi.fn(async () => {});
+
+ beforeEach(async () => {
+ vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] });
+ fetchPolicy.mockClear();
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy });
+ const { resetPolicyRefresh } = await import('@/hooks/use-policy-refresh');
+ resetPolicyRefresh();
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ delete (document as unknown as Record).visibilityState;
+ });
+
+ it('in the desktop app while hidden in the tray', async () => {
+ const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh');
+ setBridge({});
+ const { unmount } = renderHook(() => usePolicyRefresh());
+ vi.advanceTimersByTime(POLICY_REFRESH_MS);
+ expect(fetchPolicy).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it('but not in a browser tab out of view', async () => {
+ const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh');
+ const { unmount } = renderHook(() => usePolicyRefresh());
+ vi.advanceTimersByTime(POLICY_REFRESH_MS);
+ expect(fetchPolicy).not.toHaveBeenCalled();
+ unmount();
+ });
+});
+
+function dropEvent(type: string, files: File[]): Event {
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } });
+ return event;
+}
+
+describe('files on the window', () => {
+ const file = new File(['x'], 'report.pdf');
+
+ beforeEach(async () => {
+ const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
+ clearHandoffs();
+ takeHandoff('compose');
+ });
+
+ it('nothing happens in a browser', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ const stop = startWindowFiles();
+ const event = dropEvent('drop', [file]);
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ stop();
+ });
+
+ it('a drop on the window starts a new draft with the file', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const over = dropEvent('dragover', [file]);
+ window.dispatchEvent(over);
+ expect(over.defaultPrevented).toBe(true);
+ const drop = dropEvent('drop', [file]);
+ window.dispatchEvent(drop);
+ expect(drop.defaultPrevented).toBe(true);
+ expect(takeHandoff('compose')?.files).toEqual([file]);
+ stop();
+ });
+
+ it('a drop something else took is left alone', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const event = dropEvent('drop', [file]);
+ event.preventDefault();
+ window.dispatchEvent(event);
+ expect(takeHandoff('compose')).toBeNull();
+ stop();
+ });
+
+ it('text dragged around is not a file', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ setBridge({});
+ const stop = startWindowFiles();
+ const event = dropEvent('dragover', []);
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ stop();
+ });
+
+ it('an open composer takes the file itself', async () => {
+ const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const target = vi.fn();
+ const clear = setComposerFileTarget(target, () => true);
+ window.dispatchEvent(dropEvent('drop', [file]));
+ expect(target).toHaveBeenCalledWith([file]);
+ expect(takeHandoff('compose')).toBeNull();
+ clear();
+ stop();
+ });
+
+ it('a composer out of sight on a hidden tab takes nothing', async () => {
+ const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const hidden = vi.fn();
+ const clear = setComposerFileTarget(hidden, () => false);
+ window.dispatchEvent(dropEvent('drop', [file]));
+ expect(hidden).not.toHaveBeenCalled();
+ expect(takeHandoff('compose')?.files).toEqual([file]);
+ clear();
+ stop();
+ });
+
+ it('a pasted file is taken; pasted text is not', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const paste = (files: File[]) => {
+ const event = new Event('paste', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } });
+ window.dispatchEvent(event);
+ return event;
+ };
+ expect(paste([]).defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ expect(paste([file]).defaultPrevented).toBe(true);
+ expect(takeHandoff('compose')?.files).toEqual([file]);
+ stop();
+ });
+
+ describe('a paste that is not for us', () => {
+ const pasteOn = (target: EventTarget, types: string[]) => {
+ const event = new Event('paste', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } });
+ target.dispatchEvent(event);
+ return event;
+ };
+
+ it('text with a file, as a spreadsheet copies, stays text', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ stop();
+ });
+
+ it('in a search field or any other input, a file is not an attachment', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const input = document.body.appendChild(document.createElement('input'));
+ expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ input.remove();
+ stop();
+ });
+
+ it('in the body of a composer on screen, a bare file attaches', async () => {
+ const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
+ setBridge({});
+ const stop = startWindowFiles();
+ const target = vi.fn();
+ const clear = setComposerFileTarget(target, () => true);
+ const composer = document.body.appendChild(document.createElement('div'));
+ composer.setAttribute('data-testid', 'email-composer');
+ const body = composer.appendChild(document.createElement('div'));
+ body.setAttribute('contenteditable', 'true');
+ pasteOn(body, ['Files']);
+ expect(target).toHaveBeenCalledWith([file]);
+ composer.remove();
+ clear();
+ stop();
+ });
+ });
+
+ it('our own native drag over our own window is not a new file', async () => {
+ const { setOwnDragActive, startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ setOwnDragActive(true);
+ window.dispatchEvent(dropEvent('drop', [file]));
+ expect(takeHandoff('compose')).toBeNull();
+ setOwnDragActive(false);
+ stop();
+ });
+});
+
+describe('dragging an attachment out', () => {
+ const blobUrl = 'blob:https://app/1';
+ const source = {
+ name: 'report.pdf',
+ type: 'application/pdf',
+ getBlobUrl: async () => blobUrl,
+ getBlob: async () => new Blob([new Uint8Array([1, 2, 3])]),
+ };
+
+ async function ready() {
+ const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
+ const hook = renderHook(() => useAttachmentDrag(source, true));
+ act(() => hook.result.current.onPointerEnter());
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ return hook;
+ }
+
+ it('in the app: the OS\'s own drag, with the bytes for a real file', async () => {
+ const startDrag = vi.fn(async () => true);
+ setBridge({ startDrag, copyFiles: vi.fn() as never });
+ const hook = await ready();
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.dataTransfer.setData).not.toHaveBeenCalled();
+ await waitFor(() => expect(startDrag).toHaveBeenCalled());
+ const [files] = startDrag.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]];
+ expect(files[0].name).toBe('report.pdf');
+ expect([...files[0].data]).toEqual([1, 2, 3]);
+ });
+
+ it('in the app, a drag before the bytes are here starts nothing and fetches them for the next', async () => {
+ const startDrag = vi.fn(async () => true);
+ setBridge({ startDrag, copyFiles: vi.fn() as never });
+ const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
+ const hook = renderHook(() => useAttachmentDrag(source, true));
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(startDrag).not.toHaveBeenCalled();
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ hook.result.current.onDragStart(event as never);
+ await waitFor(() => expect(startDrag).toHaveBeenCalled());
+ });
+
+ it('in the app, without getBlob (a decrypted attachment): the browser drag, and no copy button', async () => {
+ const startDrag = vi.fn(async () => true);
+ setBridge({ startDrag, copyFiles: vi.fn() as never });
+ const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
+ const { name, type, getBlobUrl } = source;
+ const hook = renderHook(() => useAttachmentDrag({ name, type, getBlobUrl }, true));
+ act(() => hook.result.current.onPointerEnter());
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(startDrag).not.toHaveBeenCalled();
+ expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', expect.stringContaining(blobUrl));
+ expect(hook.result.current.onCopyAsFile).toBeUndefined();
+ });
+
+ it('in a browser: still DownloadURL, and no copy button', async () => {
+ const hook = await ready();
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `application/pdf:report.pdf:${blobUrl}`);
+ expect(hook.result.current.onCopyAsFile).toBeUndefined();
+ });
+
+ it('Copy as file hands the bytes to main', async () => {
+ const copyFiles = vi.fn(async () => true);
+ setBridge({ startDrag: vi.fn() as never, copyFiles });
+ const hook = await ready();
+ await expect(hook.result.current.onCopyAsFile?.()).resolves.toBe(true);
+ const [files] = copyFiles.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]];
+ expect(files[0].name).toBe('report.pdf');
+ });
+});
+
+describe('waking up', () => {
+ async function started(os: Partial = {}) {
+ let fire: (event: DesktopOsPowerEvent) => void = () => {};
+ setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os });
+ const power = await import('../power');
+ 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 () => {
+ const { fire, power, stop } = await started();
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ const fetchPolicy = vi.fn(async () => {});
+ usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy });
+ const visible = vi.fn();
+ const online = vi.fn();
+ const wake = vi.fn();
+ document.addEventListener('visibilitychange', visible);
+ window.addEventListener('online', online);
+ const stopWake = power.addWakeHandler(wake);
+ fire('resume');
+ fire('unlock-screen');
+ expect(visible).toHaveBeenCalledTimes(1);
+ expect(online).toHaveBeenCalledTimes(1);
+ expect(fetchPolicy).toHaveBeenCalledTimes(1);
+ expect(wake).toHaveBeenCalledTimes(1);
+ document.removeEventListener('visibilitychange', visible);
+ window.removeEventListener('online', online);
+ stopWake();
+ stop();
+ });
+
+ it('a suspend tells the sleep handlers and wakes nothing', async () => {
+ const { fire, power, stop } = await started();
+ const sleep = vi.fn();
+ const visible = vi.fn();
+ const stopSleep = power.addSleepHandler(sleep);
+ document.addEventListener('visibilitychange', visible);
+ fire('suspend');
+ fire('lock-screen');
+ expect(sleep).toHaveBeenCalledTimes(1);
+ expect(visible).not.toHaveBeenCalled();
+ document.removeEventListener('visibilitychange', visible);
+ stopSleep();
+ stop();
+ });
+
+ it('a new kind of network counts as waking; a change in its speed estimate does not', async () => {
+ const connection = Object.assign(new EventTarget(), { type: 'wifi' });
+ vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } }));
+ const { power, stop } = await started();
+ const wake = vi.fn();
+ const stopWake = power.addWakeHandler(wake);
+ connection.dispatchEvent(new Event('change'));
+ expect(wake).not.toHaveBeenCalled();
+ connection.type = 'ethernet';
+ connection.dispatchEvent(new Event('change'));
+ expect(wake).toHaveBeenCalledTimes(1);
+ stopWake();
+ stop();
+ });
+
+ it('where Chromium gives no type, a change is nothing to go on', async () => {
+ const connection = new EventTarget();
+ vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } }));
+ const { power, stop } = await started();
+ const wake = vi.fn();
+ const stopWake = power.addWakeHandler(wake);
+ connection.dispatchEvent(new Event('change'));
+ expect(wake).not.toHaveBeenCalled();
+ stopWake();
+ stop();
+ });
+
+ it('woken up with no network, it does not say the network is back, and a wake within 30 s is the same wake', async () => {
+ const online = vi.fn();
+ window.addEventListener('online', online);
+ vi.stubGlobal('navigator', Object.create(navigator, { onLine: { value: false } }));
+ const { fire, power, stop } = await started();
+ const wake = vi.fn();
+ const stopWake = power.addWakeHandler(wake);
+ fire('resume');
+ expect(wake).toHaveBeenCalledTimes(1);
+ expect(online).not.toHaveBeenCalled();
+ const now = Date.now();
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 29_000);
+ fire('unlock-screen');
+ expect(wake).toHaveBeenCalledTimes(1);
+ clock.mockReturnValue(now + 31_000);
+ fire('unlock-screen');
+ expect(wake).toHaveBeenCalledTimes(2);
+ clock.mockRestore();
+ window.removeEventListener('online', online);
+ stopWake();
+ stop();
+ });
+});
+
+describe('the summary', () => {
+ const event = (id: string, start: string, over: Partial = {}) => ({ id, title: id, start, utcStart: start, status: 'confirmed', showWithoutTime: false, ...over }) as unknown as CalendarEvent;
+ const now = new Date('2026-11-03T10:00:00Z').getTime();
+
+ it('the next event is the soonest one still ahead, not cancelled and not all-day', async () => {
+ const { nextEventOf } = await import('../os-summary');
+ const events = [
+ event('later', '2026-11-03T15:00:00Z'),
+ event('past', '2026-11-03T09:00:00Z'),
+ event('cancelled', '2026-11-03T11:00:00Z', { status: 'cancelled' }),
+ event('allday', '2026-11-03T10:30:00Z', { showWithoutTime: true }),
+ event('soon', '2026-11-03T12:00:00Z'),
+ ];
+ expect(nextEventOf(events, now)?.id).toBe('soon');
+ expect(nextEventOf([event('past', '2026-11-03T09:00:00Z')], now)).toBeNull();
+ });
+
+ describe('with a login', () => {
+ const row = { id: 'e1', threadId: 't1', subject: 'Lunch?', from: [{ name: 'Alice', email: 'a@example.org' }] };
+ const getEmails = vi.fn(async () => ({ emails: [row], hasMore: false, total: 1 }));
+ const fake = { getAccountId: () => 'acc', getEmails, queryCalendarEvents: vi.fn(async () => []) };
+
+ beforeEach(async () => {
+ getEmails.mockClear();
+ const { useAuthStore } = await import('@/stores/auth-store');
+ const { useAccountStore } = await import('@/stores/account-store');
+ const { useEmailStore } = await import('@/stores/email-store');
+ useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' });
+ useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never });
+ useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never });
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ usePolicyStore.setState({ loaded: true });
+ });
+
+ afterEach(async () => {
+ const { useAuthStore } = await import('@/stores/auth-store');
+ useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null });
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(null);
+ });
+
+ it('lists unread mail with what the tray item needs to open it', async () => {
+ const { readSummary } = await import('../os-summary');
+ const summary = await readSummary('en');
+ expect(summary.unread).toEqual([expect.objectContaining({ emailId: 'e1', accountId: 'acc', sender: 'Alice', subject: 'Lunch?' })]);
+ expect(JSON.parse(summary.unread[0].context!)).toMatchObject({ emailId: 'e1', slot: 3 });
+ expect(getEmails).toHaveBeenCalledWith('in', undefined, 5, 0, undefined, false, { notKeyword: '$seen' });
+ });
+
+ it('before the policy is known it is empty', async () => {
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ const { readSummary, EMPTY_SUMMARY } = await import('../os-summary');
+ usePolicyStore.setState({ loaded: false });
+ await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY);
+ expect(getEmails).not.toHaveBeenCalled();
+ });
+
+ it('a read under way when the login goes doesn\'t bring it back; a policy change reads again', async () => {
+ const setSummary = vi.fn();
+ setBridge({ setSummary });
+ const { startOsSummary, EMPTY_SUMMARY } = await import('../os-summary');
+ const { useAuthStore } = await import('@/stores/auth-store');
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ let answer: (value: unknown) => void = () => {};
+ getEmails.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve as never; }) as never);
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
+ const summary = startOsSummary(() => 'en');
+ await vi.advanceTimersByTimeAsync(2100);
+ useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null });
+ expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY);
+ answer({ emails: [row], hasMore: false, total: 1 });
+ await vi.advanceTimersByTimeAsync(10);
+ await new Promise((resolve) => setImmediate(resolve));
+ await vi.advanceTimersByTimeAsync(2100);
+ expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY);
+ expect(JSON.stringify(setSummary.mock.calls)).not.toContain('Lunch');
+
+ setSummary.mockClear();
+ usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy } });
+ await vi.advanceTimersByTimeAsync(2100);
+ expect(setSummary).toHaveBeenCalled();
+ summary.stop();
+ vi.useRealTimers();
+ });
+
+ it('while locked it is empty, without asking anyone anything', async () => {
+ const { setAppLockCheck } = await import('../app-lock-state');
+ const { readSummary, EMPTY_SUMMARY } = await import('../os-summary');
+ setAppLockCheck(() => true);
+ await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY);
+ expect(getEmails).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('the settings section', () => {
+ const settings = { closeToTray: true, startAtLogin: false, startMinimized: false, attentionOnNewMail: true, quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null };
+ const status = (over = {}): DesktopOsStatus => ({ settings: { ...settings, ...over }, pausedUntil: null, shortcutActive: false });
+
+ function bridgeFor(current: DesktopOsStatus) {
+ return {
+ getStatus: vi.fn(async () => current),
+ setSettings: vi.fn(async (patch: Partial) => ({ ...current, settings: { ...current.settings, ...patch } })),
+ onStatusChange: vi.fn(() => () => {}),
+ defaultAppStatus: vi.fn(async () => ({ registered: false, mailto: false, webcal: false })),
+ openDefaultAppSettings: vi.fn(async () => ({ registered: true, mailto: false, webcal: false })),
+ };
+ }
+
+ it('a browser never sees it', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('in the app it shows the state and changes it', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ const os = bridgeFor(status());
+ setBridge(os as never);
+ render();
+ expect(await screen.findByText('section_title')).toBeInTheDocument();
+ expect(screen.queryByText('start_minimized_label')).toBeNull();
+ // Second in the section, after Close to tray.
+ fireEvent.click(screen.getAllByRole('switch')[1]);
+ await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ startAtLogin: true }));
+ expect(await screen.findByText('start_minimized_label')).toBeInTheDocument();
+ });
+
+ it('the button offers the app and opens where the choice is made', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ const os = bridgeFor(status());
+ setBridge(os as never);
+ render();
+ fireEvent.click(await screen.findByRole('button', { name: 'default_app_button' }));
+ await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled());
+ });
+
+ it('says when another app holds the shortcut', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never);
+ render();
+ expect(await screen.findByText(/show_shortcut_taken/)).toBeInTheDocument();
+ });
+
+ it('macOS gets its own word for the bounce', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ setBridge(bridgeFor(status()) as never, 'darwin');
+ render();
+ expect(await screen.findByText('attention_mac_label')).toBeInTheDocument();
+ });
+});
diff --git a/lib/desktop/__tests__/reminder-notifications.test.ts b/lib/desktop/__tests__/reminder-notifications.test.ts
new file mode 100644
index 000000000..f609dae3b
--- /dev/null
+++ b/lib/desktop/__tests__/reminder-notifications.test.ts
@@ -0,0 +1,305 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useMissedReminders } from '@/lib/calendar-reminders/state';
+import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
+import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
+import { setStoreBackend } from '@/lib/store/client/api';
+import type { ReminderAlert, TickResult } from '@/lib/store/reminders/ledger';
+import { setAppLockCheck } from '../app-lock-state';
+import {
+ buildMissedNotification,
+ buildReminderNotification,
+ handleReminderCommand,
+ joinMeeting,
+ missedBody,
+ notifyWithoutStore,
+ type ReminderLabels,
+} from '../reminder-notifications';
+import type { BulwarkDesktopBridge } from '../types';
+
+const ACCOUNT = 'alice@one';
+const accounts = [{ id: ACCOUNT, cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }];
+let activeAccountId: string | null = ACCOUNT;
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId }), subscribe: () => () => {} } }));
+vi.mock('@/stores/policy-store', () => ({ usePolicyStore: { getState: () => ({ loaded: true }), subscribe: () => () => {} } }));
+
+const policy = vi.hoisted(() => ({ pushWithoutContent: false }));
+vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent: policy.pushWithoutContent }) }));
+
+const MIN = 60_000;
+const START = Date.UTC(2026, 10, 10, 9);
+const FIRE = START - 15 * MIN;
+
+const labels: ReminderLabels = {
+ generic: 'GENERIC',
+ when: () => 'In 15 min · Personal',
+ untitled: 'UNTITLED',
+ snooze: (minutes) => `Snooze ${minutes} min`,
+ snoozeShort: (minutes) => `${minutes} min`,
+ open: 'Open',
+ join: 'Join',
+ joinHost: (host) => `Join (${host})`,
+ missedTitle: (count) => `${count} missed`,
+ missedMore: (count) => `and ${count} more`,
+};
+
+function alert(partial: Partial = {}): ReminderAlert {
+ return {
+ key: `j1:uid-1:a:${FIRE}`, jmap: 'j1', uid: 'uid-1', kind: 'event', baseEventId: 'e1', recurrenceId: null, alertId: 'a', fireTimeMs: FIRE,
+ title: 'Secret board meeting', start: '2026-11-10T10:00:00', startMs: START, joinUrl: null, joinHost: null, timeZone: 'Europe/Berlin', calendarId: 'cal1', calendarName: 'Personal',
+ ...partial,
+ };
+}
+
+const focus = vi.fn();
+const openExternal = vi.fn();
+const notify = vi.fn(async () => ({ shown: true, playSound: false }));
+
+function setPlatform(platform: string) {
+ window.bulwarkDesktop = { version: 1, platform, windows: { focus }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge;
+}
+
+let store: InProcessStore;
+let server: FakeCalendarJmap;
+const env = { leaveFor: vi.fn(), push: vi.fn() };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ policy.pushWithoutContent = false;
+ activeAccountId = ACCOUNT;
+ setAppLockCheck(null);
+ setPlatform('win32');
+ useMissedReminders.getState().close();
+ server = new FakeCalendarJmap('1.0');
+ store = new InProcessStore(server, CALENDAR_SESSION);
+ setStoreBackend(store);
+});
+
+afterEach(() => {
+ setStoreBackend(null);
+ window.bulwarkDesktop = undefined;
+ setAppLockCheck(null);
+ vi.useRealTimers();
+});
+
+async function contextFor(reminder: ReminderAlert): Promise {
+ return (await buildReminderNotification(ACCOUNT, reminder, labels, false)).context;
+}
+
+describe('what a reminder says', () => {
+ it('offers Join only when the event has a link', async () => {
+ const without = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ expect(without.actions!.map((action) => action.id)).toEqual(['open', 'snooze-5', 'snooze-10', 'snooze-15']);
+ const withLink = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, false);
+ expect(withLink.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']);
+ expect(withLink.actions![0].label).toBe('Join');
+ const foreign = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://calls.example.org/x', joinHost: 'calls.example.org' }), labels, false);
+ expect(foreign.actions![0].label).toBe('Join (calls.example.org)');
+ });
+
+ it('uses short snooze labels where five buttons share a row', async () => {
+ const windows = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ expect(windows.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('5 min');
+ setPlatform('darwin');
+ const mac = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ expect(mac.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('Snooze 5 min');
+ });
+
+ it('sends nothing of the event while the app is locked: no title, no time, no key, no buttons', async () => {
+ setAppLockCheck(() => true);
+ const locked = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, true);
+ expect(locked).toMatchObject({ title: 'GENERIC', body: '', context: '', kind: 'reminder' });
+ expect(locked.actions).toBeUndefined();
+ expect(JSON.stringify(locked)).not.toContain('Secret');
+ expect(JSON.stringify(locked)).not.toContain('uid-1');
+ });
+
+ it('keeps the title off when the organisation says so, the buttons stay', async () => {
+ policy.pushWithoutContent = true;
+ const hidden = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ expect(hidden).toMatchObject({ title: 'GENERIC', body: '' });
+ expect(hidden.actions).toHaveLength(4);
+ });
+
+ it('gets a new id when it comes back from a snooze, since main drops a repeat', async () => {
+ const first = await buildReminderNotification(ACCOUNT, alert(), labels, false);
+ const back = await buildReminderNotification(ACCOUNT, alert({ snoozedUntil: FIRE + 5 * MIN }), labels, false);
+ expect(back.id).not.toBe(first.id);
+ });
+
+ it('falls back on a neutral title for an event without one', async () => {
+ expect((await buildReminderNotification(ACCOUNT, alert({ title: '' }), labels, false)).title).toBe('UNTITLED');
+ });
+
+ it('keeps titles out of a summary when locked or forbidden, and names three at most', async () => {
+ const alerts = ['A', 'B', 'C', 'D', 'E'].map((title) => alert({ title, key: title }));
+ expect(missedBody(alerts, labels)).toBe('A, B, C, and 2 more');
+ const open = await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels);
+ expect(open).toMatchObject({ title: '5 missed', body: 'A, B, C, and 2 more' });
+ setAppLockCheck(() => true);
+ expect((await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels)).body).toBe('');
+ });
+
+ it('without a store says what it can, and nothing when locked', () => {
+ notifyWithoutStore({ key: 'e1:a:1', fireTimeMs: 1, title: 'Dentist', body: 'In 5 min', sound: true }, { generic: 'GENERIC' }, ACCOUNT);
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(notify.mock.calls[0]).toEqual([expect.objectContaining({ id: 'reminder:e1:a:1', title: 'Dentist', body: 'In 5 min', context: '{"cal":true}', sound: true })]);
+ setAppLockCheck(() => true);
+ notifyWithoutStore({ key: 'e1:a:1', fireTimeMs: 1, title: 'Dentist', body: 'In 5 min', sound: true }, { generic: 'GENERIC' }, ACCOUNT);
+ expect(notify).toHaveBeenCalledTimes(2);
+ expect(notify.mock.calls[1]).toEqual([expect.objectContaining({ title: 'GENERIC', body: '' })]);
+ expect(JSON.stringify(notify.mock.calls[1])).not.toContain('Dentist');
+ expect(JSON.stringify(notify.mock.calls[1])).not.toContain('e1');
+ });
+});
+
+describe('sizes', () => {
+ it('keeps the context under what main will take, with the longest key the store makes', async () => {
+ const key = `${'j'.repeat(40)}:#abc:9999:${'a'.repeat(64)}:${FIRE}`;
+ const { context } = await buildReminderNotification(ACCOUNT, alert({ key, uid: 'u'.repeat(5000) }), labels, false);
+ expect(context.length).toBeLessThan(1024);
+ expect(JSON.parse(context).r).toBe(key);
+ });
+});
+
+describe('Join is only ever a web link', () => {
+ for (const url of ['ms-msdt:/id PCWDiagnostic', 'search-ms:query=x', 'smb://evil.example/share', 'https://meet.google.com@evil.example/x', 'javascript:alert(1)', 'not a link']) {
+ it(`opens nothing for ${url}, in the app`, async () => {
+ expect(await joinMeeting(url)).toBe(false);
+ expect(openExternal).not.toHaveBeenCalled();
+ });
+
+ it(`opens nothing for ${url}, in a browser`, async () => {
+ window.bulwarkDesktop = undefined;
+ const open = vi.spyOn(window, 'open').mockImplementation(() => null);
+ expect(await joinMeeting(url)).toBe(false);
+ expect(open).not.toHaveBeenCalled();
+ open.mockRestore();
+ });
+ }
+
+ it('opens a web link, in the app and in a browser', async () => {
+ expect(await joinMeeting('https://meet.jit.si/x')).toBe(true);
+ expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/x');
+ window.bulwarkDesktop = undefined;
+ const open = vi.spyOn(window, 'open').mockImplementation(() => null);
+ expect(await joinMeeting('https://meet.jit.si/y')).toBe(true);
+ expect(open).toHaveBeenCalledWith('https://meet.jit.si/y', '_blank', 'noopener,noreferrer');
+ open.mockRestore();
+ });
+
+ it('leaves the reminder standing when Join from a toast opened nothing', async () => {
+ vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] });
+ server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } }, start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } });
+ await store.sync(ACCOUNT);
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ await store.area(ACCOUNT, 'reminders.tick', params);
+ vi.setSystemTime(FIRE);
+ const [reminder] = (await store.area(ACCOUNT, 'reminders.tick', params)).due;
+ const { uiHooks } = await import('@/lib/plugin-hooks');
+ const stop = uiHooks.onBeforeExternalLink.register('t', () => false);
+ const area = vi.spyOn(store, 'area');
+ await handleReminderCommand('join', await contextFor(reminder), env);
+ stop.dispose();
+ expect(openExternal).not.toHaveBeenCalled();
+ expect(area.mock.calls.filter(([, name]) => name === 'reminders.dismiss')).toEqual([]);
+ // The window comes up, since the button did nothing else.
+ expect(focus).toHaveBeenCalled();
+ });
+});
+
+describe('what its buttons do', () => {
+ /** A reminder really on record in the store. */
+ async function fired(partial: Record = {}): Promise {
+ vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] });
+ server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, ...partial });
+ await store.sync(ACCOUNT);
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ await store.area(ACCOUNT, 'reminders.tick', params);
+ vi.setSystemTime(FIRE);
+ return (await store.area(ACCOUNT, 'reminders.tick', params)).due[0];
+ }
+
+ it('snoozes from the store with the window left hidden', async () => {
+ const reminder = await fired();
+ expect(await handleReminderCommand('snooze-10', await contextFor(reminder), env)).toBe(true);
+ expect(focus).not.toHaveBeenCalled();
+ expect(env.leaveFor).not.toHaveBeenCalled();
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ vi.setSystemTime(FIRE + 9 * MIN);
+ expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
+ vi.setSystemTime(FIRE + 10 * MIN);
+ expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toHaveLength(1);
+ });
+
+ it('refuses a snooze length no button offers', async () => {
+ const reminder = await fired();
+ await handleReminderCommand('snooze-999', await contextFor(reminder), env);
+ expect(focus).toHaveBeenCalled();
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ vi.setSystemTime(FIRE + 16 * MIN);
+ expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
+ });
+
+ it('joins through the OS from what the store holds, with the window left hidden', async () => {
+ const reminder = await fired({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } });
+ await handleReminderCommand('join', await contextFor(reminder), env);
+ expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup');
+ expect(focus).not.toHaveBeenCalled();
+ });
+
+ it('opens the event in the account on screen', async () => {
+ const reminder = await fired();
+ await handleReminderCommand('open', await contextFor(reminder), env);
+ expect(focus).toHaveBeenCalled();
+ expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
+ expect(env.push).toHaveBeenCalledWith('/calendar/event/e1?calendar=cal1');
+ });
+
+ it('opens only the calendar for an account that is not on screen', async () => {
+ const reminder = await fired();
+ const context = await contextFor(reminder);
+ activeAccountId = 'someone-else';
+ await handleReminderCommand('open', context, env);
+ expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
+ expect(env.push).not.toHaveBeenCalled();
+ });
+
+ it('touches nothing when the slot has gone to another login since', async () => {
+ const reminder = await fired();
+ const context = JSON.parse(await contextFor(reminder)) as { login: string };
+ context.login = 'not-this-login';
+ await handleReminderCommand('snooze-5', JSON.stringify(context), env);
+ expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ vi.setSystemTime(FIRE + 5 * MIN);
+ expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
+ });
+
+ it('opens the summary from a click on its toast, even after a restart', async () => {
+ vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] });
+ server.addEvent({ id: 'e1', uid: 'uid-1', title: 'One', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } });
+ server.addEvent({ id: 'e2', uid: 'uid-2', title: 'Two', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } });
+ await store.sync(ACCOUNT);
+ const params = { timeZone: 'Europe/Berlin', tasks: true };
+ await store.area(ACCOUNT, 'reminders.tick', params);
+ vi.setSystemTime(FIRE + 60 * MIN);
+ await store.area(ACCOUNT, 'reminders.tick', params);
+ const batch = await store.area<{ batch: number; alerts: ReminderAlert[] }>(ACCOUNT, 'reminders.takeMissed', params);
+ const toast = await buildMissedNotification(ACCOUNT, batch, labels);
+
+ await handleReminderCommand('open', toast.context, env);
+ expect(focus).toHaveBeenCalled();
+ expect(useMissedReminders.getState().alerts.map((a) => a.title)).toEqual(['One', 'Two']);
+ expect(useMissedReminders.getState().account).toBe(ACCOUNT);
+ });
+
+ it('goes to the calendar for a store-less reminder, and leaves mail toasts alone', async () => {
+ expect(await handleReminderCommand('open', JSON.stringify({ cal: true }), env)).toBe(true);
+ expect(env.leaveFor).toHaveBeenCalledTimes(1);
+ expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
+ expect(await handleReminderCommand('open', JSON.stringify({ emailId: 'm1', slot: 3 }), env)).toBe(false);
+ expect(await handleReminderCommand('open', '', env)).toBe(false);
+ expect(await handleReminderCommand('open', '{broken', env)).toBe(false);
+ });
+});
diff --git a/lib/desktop/app-lock-state.ts b/lib/desktop/app-lock-state.ts
index b85f90145..9366d8eeb 100644
--- a/lib/desktop/app-lock-state.ts
+++ b/lib/desktop/app-lock-state.ts
@@ -2,8 +2,11 @@
* Whether the app is locked, for everything that would show mail outside the app.
*
* lib/app-lock/controller.ts calls setAppLockCheck once, at the start of each page, with
- * a function that answers from its own state. Main keeps its own answer for the tray, the
- * jump list and toasts (the shell's app-lock-gate.ts).
+ * a function that answers from its own state, and calls bridge.os.setLocked(true) when it
+ * locks. components/desktop/desktop-os-host.tsx says it's open again when the app starts
+ * behind the lock. Main then clears the tray, the jump list and delivered toasts, and strips
+ * anything that still comes through. Main keeps its own answer too (the shell's
+ * app-lock-gate.ts), which stands over what any page says.
*/
let check: (() => boolean) | null = null;
diff --git a/lib/desktop/bridge.ts b/lib/desktop/bridge.ts
index 922a5c1b5..33e36bd81 100644
--- a/lib/desktop/bridge.ts
+++ b/lib/desktop/bridge.ts
@@ -19,6 +19,11 @@ export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signe
getDesktopBridge()?.servers?.forget?.(serverUrl, reason);
}
+/** Nothing of an account stays on the OS's surfaces after a sign-out or a wipe. */
+export function clearDesktopSurfaces(): void {
+ getDesktopBridge()?.os?.clearSurfaces?.();
+}
+
/** True in any window of the desktop app, standalone or on an instance. */
export function isDesktopApp(): boolean {
return getDesktopBridge() !== null;
diff --git a/lib/desktop/desktop-telemetry.ts b/lib/desktop/desktop-telemetry.ts
new file mode 100644
index 000000000..7eca1d05f
--- /dev/null
+++ b/lib/desktop/desktop-telemetry.ts
@@ -0,0 +1,165 @@
+/**
+ * What the desktop app's telemetry and crash reports agree on across the webmail and
+ * the shell: when consent counts, what a heartbeat from the shell holds, and when one
+ * is due.
+ *
+ * No DOM, no Node, no imports: the desktop repo keeps a byte-for-byte copy in
+ * src/shared/desktop-telemetry.ts. Main can't import lib/telemetry, which needs the
+ * store and node:dns, and in instance mode no server runs inside the app. It holds no
+ * host name either: the shell has its own constant, and lib/telemetry/types.ts has this
+ * side's.
+ *
+ * The collector decides the shape (repos/dashboard/telemetry-collector/src/validate.ts).
+ * Schema 1 drops fields it doesn't know and schema 2 refuses the whole heartbeat over
+ * one, so mode and channel travel in `build`, the one free field it keeps. It stores
+ * `platform: 'desktop'` as `unknown` until it has been patched.
+ */
+
+export type DesktopTelemetryMode = 'standalone' | 'instance';
+export type DesktopTelemetryChannel = 'stable' | 'beta';
+export type DesktopOsFamily = 'linux' | 'darwin' | 'windows' | 'unknown';
+
+/** What main tells the embedded server, by message. Nothing in a page can write it. */
+export interface DesktopServerPrivacy {
+ telemetry: boolean;
+ crashReports: boolean;
+ /** The desktop app's version. The webmail's own is in `NEXT_PUBLIC_APP_VERSION`. */
+ appVersion: string;
+ /** `desktop..`. */
+ build: string;
+ /** Only a test build of the shell sets this. Nothing on disk can: see `telemetryEndpoint`. */
+ endpoint?: string;
+}
+
+/**
+ * How soon after a start a heartbeat that's due goes out. The server's own hour is right
+ * for a server: an app quit within an hour of every start would never send. Random, so
+ * the collector can't read when someone starts their computer from when it arrives.
+ */
+export const DESKTOP_CATCH_UP_MIN_MS = 30_000;
+export const DESKTOP_CATCH_UP_MAX_MS = 600_000;
+
+export function desktopCatchUpMs(random: () => number = Math.random): number {
+ return DESKTOP_CATCH_UP_MIN_MS + Math.floor(random() * (DESKTOP_CATCH_UP_MAX_MS - DESKTOP_CATCH_UP_MIN_MS));
+}
+
+/** A failed send tries again this much later, not a day later. */
+export const DESKTOP_RETRY_MS = 60 * 60_000;
+export const DESKTOP_HEARTBEAT_INTERVAL_MS = 24 * 60 * 60_000;
+
+/** The start of a main-process frame's path. Only the shell's own bundle gets it. */
+export const DESKTOP_FRAME_PREFIX = '/desktop/';
+/** On the version of everything a desktop app reports. The collector's version field allows it. */
+export const DESKTOP_VERSION_SUFFIX = '+desktop';
+
+/** A policy that forces something off. A recommendation doesn't: the user's choice beats it. */
+export function forcedOffByPolicy(entry: { value: boolean; locked: boolean }): boolean {
+ return entry.locked && entry.value === false;
+}
+
+/**
+ * Whether something opt-in is on. Only a yes counts: no answer is off, and a policy
+ * allowing it still leaves the question to the user.
+ */
+export function consentInForce(choice: boolean | null | undefined, policy: { value: boolean; locked: boolean }): boolean {
+ return choice === true && !forcedOffByPolicy(policy);
+}
+
+export function desktopBuildTag(mode: DesktopTelemetryMode, channel: DesktopTelemetryChannel): string {
+ return `desktop.${mode}.${channel}`;
+}
+
+/** Forced beats the user's choice, which beats what the version says. */
+export function desktopChannel(
+ policy: { value: DesktopTelemetryChannel; locked: boolean },
+ choice: unknown,
+ appVersion: string,
+): DesktopTelemetryChannel {
+ if (policy.locked) return policy.value;
+ if (choice === 'stable' || choice === 'beta') return choice;
+ return appVersion.includes('-') ? 'beta' : 'stable';
+}
+
+export function desktopOsFamily(platform: string): DesktopOsFamily {
+ if (platform === 'win32') return 'windows';
+ if (platform === 'darwin') return 'darwin';
+ if (platform === 'linux') return 'linux';
+ return 'unknown';
+}
+
+/** How long until the next heartbeat, given when the last one went out. */
+export function heartbeatDelayMs(lastSentAt: number | null | undefined, now: number, catchUp: number = desktopCatchUpMs()): number {
+ if (!lastSentAt || lastSentAt > now) return catchUp;
+ const left = lastSentAt + DESKTOP_HEARTBEAT_INTERVAL_MS - now;
+ return left > 0 ? left : catchUp;
+}
+
+export interface DesktopPingInput {
+ /** The shell's own UUID, never an instance's. */
+ instanceId: string;
+ appVersion: string;
+ nodeVersion: string;
+ osFamily: DesktopOsFamily;
+ mode: DesktopTelemetryMode;
+ channel: DesktopTelemetryChannel;
+ now: number;
+}
+
+/**
+ * The shell's own heartbeat, for when no server runs inside the app. Features and counts
+ * are the instance's to report, so these are all empty and nothing is counted twice.
+ */
+export function buildDesktopPing(input: DesktopPingInput) {
+ return {
+ schema: '1' as const,
+ instance_id: input.instanceId,
+ ts: new Date(input.now).toISOString(),
+ version: input.appVersion,
+ build: desktopBuildTag(input.mode, input.channel),
+ platform: 'desktop',
+ node_version: input.nodeVersion,
+ os_family: input.osFamily,
+ stalwart_version: null,
+ features: { calendar: false, contacts: false, files: false, extensions: false, oauth_enabled: false, smime_enabled: false },
+ counts: { accounts: '0', accounts_active_7d: '0', extensions_installed: 0, themes_installed: 0 },
+ uptime_days: 0,
+ };
+}
+
+export function desktopErrorCountBucket(count: number): string {
+ if (count <= 1) return '1';
+ if (count <= 5) return '2-5';
+ if (count <= 10) return '6-10';
+ if (count <= 50) return '11-50';
+ if (count <= 200) return '51-200';
+ return '201+';
+}
+
+export interface DesktopMainError {
+ name: string;
+ /** 16 hex digits. */
+ fingerprint: string;
+ version: string;
+ count: number;
+}
+
+/**
+ * Main's errors when no server runs inside the app: the collector's /v1/errors takes
+ * this without a heartbeat, so it works with telemetry off. Name and count only. The
+ * collector refuses a whole batch over a frame outside the webmail's bundles.
+ */
+export function buildDesktopErrorBatch(instanceId: string, now: number, errors: readonly DesktopMainError[]) {
+ return {
+ schema: '1' as const,
+ instance_id: instanceId,
+ day: new Date(now).toISOString().slice(0, 10),
+ errors: errors.map((error) => ({
+ name: error.name,
+ fingerprint: error.fingerprint,
+ frames: [],
+ version: error.version,
+ count: desktopErrorCountBucket(error.count),
+ days: 1,
+ })),
+ };
+}
diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts
new file mode 100644
index 000000000..07084646c
--- /dev/null
+++ b/lib/desktop/file-drag.ts
@@ -0,0 +1,67 @@
+/**
+ * Mail as real files from the page's side: a native drag or a copy to the clipboard.
+ * Later agents' calls for the taskbar's progress bar live here too.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { setOwnDragActive } from '@/lib/desktop/window-files';
+
+/** The page's CSP keeps fetch off blob: URLs, so a file travels as its Blob. */
+export interface DragFile {
+ name: string;
+ blob: Blob;
+}
+
+const keptBlobs = new Map();
+
+/** In the app, remembers what's behind an object URL made for dragging. */
+export function keepDragBlob(url: string, blob: Blob): string {
+ if (canDragNativeFiles()) keptBlobs.set(url, blob);
+ return url;
+}
+
+export function keptDragBlob(url: string): Blob | undefined {
+ return keptBlobs.get(url);
+}
+
+export function forgetDragBlob(url: string): void {
+ keptBlobs.delete(url);
+}
+
+export function canDragNativeFiles(): boolean {
+ return !!getDesktopBridge()?.os?.startDrag;
+}
+
+async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> {
+ return { name: file.name, data: new Uint8Array(await file.blob.arrayBuffer()) };
+}
+
+/**
+ * Hands the file to main, which writes it to its private folder and starts the OS's
+ * drag. The mouse is still down, so this has to come straight from `dragstart`.
+ */
+export async function startNativeFileDrag(files: readonly DragFile[]): Promise {
+ const os = getDesktopBridge()?.os;
+ if (!os?.startDrag) return false;
+ setOwnDragActive(true);
+ try {
+ return await os.startDrag(await Promise.all(files.map(toBytes)));
+ } finally {
+ setOwnDragActive(false);
+ }
+}
+
+/** Copy attachments as files, for pasting into Explorer or Finder. */
+export async function copyFilesToClipboard(files: readonly DragFile[]): Promise {
+ const os = getDesktopBridge()?.os;
+ if (!os?.copyFiles) return false;
+ return os.copyFiles(await Promise.all(files.map(toBytes)));
+}
+
+/**
+ * The taskbar's progress bar, for an import, an export or a big send. 0 to 1, or null
+ * to take it away. Does nothing in a browser.
+ */
+export function setTaskbarProgress(progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error'): void {
+ getDesktopBridge()?.os?.setProgress(progress, mode);
+}
diff --git a/lib/desktop/keystore-grants.ts b/lib/desktop/keystore-grants.ts
index 0a7ad76a4..2fc4cf7df 100644
--- a/lib/desktop/keystore-grants.ts
+++ b/lib/desktop/keystore-grants.ts
@@ -9,3 +9,11 @@ import { getDesktopBridge } from './bridge';
export function clearKeystoreGrants(): void {
void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {});
}
+
+/**
+ * Deletes the crash files on this computer. They can hold the mail of whoever was signed
+ * in when the app crashed, so the next account at this computer doesn't get to send them.
+ */
+export function clearDesktopCrashFiles(): void {
+ void Promise.resolve(getDesktopBridge()?.privacy?.deleteCrashes?.()).catch(() => {});
+}
diff --git a/lib/desktop/mail-links.ts b/lib/desktop/mail-links.ts
new file mode 100644
index 000000000..12416ef30
--- /dev/null
+++ b/lib/desktop/mail-links.ts
@@ -0,0 +1,44 @@
+/**
+ * Opening a message or folder from outside the mail page: a tray item, a toast, a jump
+ * list entry. The mail page applies a link the way it applies a permalink. If it isn't
+ * on screen, the link waits and the app goes there.
+ */
+
+import type { MailDeepLink } from '@/lib/deep-links';
+
+export interface MailLinkOptions {
+ replyText?: string;
+}
+
+export type MailLinkOpener = (link: MailDeepLink, options?: MailLinkOptions) => void;
+
+let opener: MailLinkOpener | null = null;
+let waiting: { link: MailDeepLink; options?: MailLinkOptions } | null = null;
+
+/** The mail page, while it's mounted. Takes over a link that was waiting for it. */
+export function setMailLinkOpener(next: MailLinkOpener): () => void {
+ opener = next;
+ if (waiting) {
+ const { link, options } = waiting;
+ waiting = null;
+ next(link, options);
+ }
+ return () => {
+ if (opener === next) opener = null;
+ };
+}
+
+export function openMailLink(link: MailDeepLink, options: MailLinkOptions | undefined, leaveFor: (path: '/') => void | Promise): void {
+ if (opener) {
+ opener(link, options);
+ return;
+ }
+ waiting = { link, options };
+ void leaveFor('/');
+}
+
+/** For tests. */
+export function resetMailLinks(): void {
+ opener = null;
+ waiting = null;
+}
diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts
new file mode 100644
index 000000000..12cd12e5d
--- /dev/null
+++ b/lib/desktop/mail-notifications.ts
@@ -0,0 +1,180 @@
+/**
+ * New mail as native notifications, with the same rules as the app's other ways of
+ * saying so. Mail notifications off or the app's Do not disturb: nothing. Mail that
+ * arrives together is told about once. Sender and subject stay off the notification
+ * when the app is locked, or when the organisation says so for that login
+ * (pushWithoutContent, the rule the push preview route follows).
+ *
+ * Main decides what the OS allows: pause, quiet hours, Do Not Disturb. It answers
+ * whether the page should play the sound the user picked, because this is the one
+ * place that sound is played from in the desktop app.
+ */
+
+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';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+export const MAIL_BATCH_MS = 3000;
+
+export interface MailNotificationLabels {
+ newMail: string;
+ newMailCount: (count: number) => string;
+ noSubject: string;
+ archive: string | null;
+ markRead: string;
+ delete: string;
+ reply: string;
+ replyPlaceholder: string;
+}
+
+/** What a toast's button hands back, possibly after the app restarted. */
+export interface MailNotificationContext {
+ emailId?: string;
+ threadId?: string;
+ accountId?: string;
+ slot?: number;
+ login?: string;
+}
+
+/** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */
+export function desktopPlaysMailSound(): boolean {
+ return !!getDesktopBridge()?.os && !isPopoutWindow();
+}
+
+interface Sender {
+ entryId: string | null;
+ slot?: number;
+ login?: string;
+ label: string | null;
+}
+
+async function senderInfo(client: IJMAPClient): Promise {
+ const auth = useAuthStore.getState();
+ const accounts = useAccountStore.getState().accounts;
+ const entry = accounts.find((account) => auth.getClientForAccount(account.id) === client)
+ ?? (auth.client === client ? accounts.find((account) => account.id === auth.activeAccountId) : undefined);
+ if (!entry) return { entryId: null, label: null };
+ return {
+ entryId: entry.id,
+ slot: entry.cookieSlot,
+ login: await loginFingerprint(entry.serverUrl, entry.username),
+ label: accounts.length > 1 ? (entry.label || entry.email || entry.username || null) : null,
+ };
+}
+
+/** Sender and subject stay off everything outside the app. Not knowing yet counts as yes. */
+export function contentHiddenFor(accountEntryId: string | null | undefined): boolean {
+ if (isAppLocked()) return true;
+ // Unknown counts as on, as on the server's route.
+ if (!accountEntryId || !usePolicyStore.getState().loaded) return true;
+ try {
+ return devicesForAccount(accountEntryId).pushWithoutContent;
+ } catch {
+ return true;
+ }
+}
+
+function withoutContent(sender: Sender): boolean {
+ return contentHiddenFor(sender.entryId);
+}
+
+function senderName(mail: NewMail): string {
+ const first = mail.email.from?.[0];
+ return first?.name || first?.email || '';
+}
+
+export async function buildMailNotification(batch: readonly NewMail[], labels: MailNotificationLabels): Promise {
+ const newest = batch.reduce((a, b) => (new Date(b.email.receivedAt ?? 0).getTime() > new Date(a.email.receivedAt ?? 0).getTime() ? b : a));
+ const sender = await senderInfo(newest.client);
+ const hidden = withoutContent(sender);
+ const context: MailNotificationContext = { accountId: newest.accountId, slot: sender.slot, login: sender.login };
+ const base = {
+ id: `mail:${newest.accountId}/${newest.email.id}`,
+ group: `mail:${newest.accountId}`,
+ sound: true,
+ attention: true,
+ };
+ const suffix = sender.label && !hidden ? ` (${sender.label})` : '';
+ if (batch.length > 1) {
+ return {
+ ...base,
+ title: `${labels.newMailCount(batch.length)}${suffix}`,
+ body: hidden ? '' : `${senderName(newest)}: ${newest.email.subject || labels.noSubject}`,
+ // Nothing for one message to act on: a click opens the Inbox.
+ context: JSON.stringify(context),
+ };
+ }
+ const full: MailNotificationContext = { ...context, emailId: newest.email.id, threadId: newest.email.threadId };
+ if (hidden) {
+ return { ...base, title: labels.newMail, body: '', context: JSON.stringify(full) };
+ }
+ return {
+ ...base,
+ title: `${senderName(newest) || labels.newMail}${suffix}`,
+ body: newest.email.subject || newest.email.preview || labels.noSubject,
+ context: JSON.stringify(full),
+ actions: [
+ ...(labels.archive ? [{ id: 'archive', label: labels.archive }] : []),
+ { id: 'mark-read', label: labels.markRead },
+ { id: 'delete', label: labels.delete },
+ ],
+ reply: { label: labels.reply, placeholder: labels.replyPlaceholder },
+ };
+}
+
+export interface MailNotifications extends NewMailWatch {
+ setLabels(labels: MailNotificationLabels): void;
+}
+
+export function startMailNotifications(initial: MailNotificationLabels): MailNotifications {
+ let labels = initial;
+ let batch: NewMail[] = [];
+ let timer: ReturnType | null = null;
+
+ const flush = () => {
+ timer = null;
+ const current = batch;
+ batch = [];
+ if (current.length === 0) return;
+ const settings = useSettingsStore.getState();
+ if (!settings.emailNotificationsEnabled || settings.doNotDisturb) return;
+ void (async () => {
+ const notification = await buildMailNotification(current, labels);
+ const result = await getDesktopBridge()?.os?.notify({ ...notification, sound: settings.emailNotificationSound });
+ if (result?.playSound) playNotificationSound(settings.notificationSoundChoice);
+ })().catch((error) => {
+ console.error('Failed to show a notification:', error);
+ });
+ };
+
+ const watch = startNewMailWatch((mail) => {
+ batch.push(...mail);
+ timer ??= setTimeout(flush, MAIL_BATCH_MS);
+ });
+
+ const handle: MailNotifications = {
+ ...watch,
+ setLabels(next) {
+ labels = next;
+ },
+ stop() {
+ watch.stop();
+ if (timer) clearTimeout(timer);
+ timer = null;
+ setStoreCreatedListener(null);
+ },
+ };
+ setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids));
+ return handle;
+}
diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts
new file mode 100644
index 000000000..efe23173d
--- /dev/null
+++ b/lib/desktop/new-mail.ts
@@ -0,0 +1,151 @@
+/**
+ * Which messages count as new mail for the desktop app's notifications, for every
+ * connected login, whichever page or folder is open.
+ *
+ * With the local store the source is its `created` event (noteCreatedEmails): the
+ * engine has already worked out the delta. Without one, each login's push change is
+ * resolved here with Email/changes on a baseline of its own, the way
+ * reportNewInboxMail does for plugins. Both can see the same message; it's only told
+ * once.
+ */
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { isMailboxMuted } from '@/lib/notification-mutes';
+import { subscribePushStateChanges } from '@/lib/push-events';
+import { useSettingsStore } from '@/stores/settings-store';
+
+export interface NewMail {
+ client: IJMAPClient;
+ /** The JMAP account the message is in. */
+ accountId: string;
+ email: Email;
+}
+
+const MAX_PAGES = 5;
+const MAX_CHANGES = 200;
+const SEEN_LIMIT = 500;
+/** An import or a restore creates old mail: nobody is waiting to hear about that. */
+const NEWEST_AGE_MS = 24 * 60 * 60 * 1000;
+/** Folders mail lands in by itself, when not only the Inbox notifies. */
+const NOT_ARRIVALS = new Set(['junk', 'trash', 'sent', 'drafts', 'archive']);
+
+function mailboxIdsOf(email: Email): string[] {
+ return Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]);
+}
+
+/** The folder a new message counts for, or null if it stays silent. */
+export function notifyingMailbox(email: Email, accountId: string, ownAccountId: string, mailboxes: readonly Mailbox[], now = Date.now()): Mailbox | null {
+ if (email.keywords?.$seen || email.keywords?.$draft) return null;
+ const received = email.receivedAt ? new Date(email.receivedAt).getTime() : now;
+ if (now - received > NEWEST_AGE_MS) return null;
+ const { notificationMutes, pushNotifyInboxOnly } = useSettingsStore.getState();
+ const here = new Set(mailboxIdsOf(email));
+ const mine = mailboxes.filter((mailbox) => (mailbox.accountId ?? ownAccountId) === accountId && here.has(mailbox.originalId || mailbox.id));
+ const counts = mine.filter((mailbox) => (pushNotifyInboxOnly ? mailbox.role === 'inbox' : !NOT_ARRIVALS.has(mailbox.role ?? '')));
+ // Mail filed in a muted folder and an unmuted one still notifies.
+ return counts.find((mailbox) => !isMailboxMuted(notificationMutes, accountId, mailbox.originalId || mailbox.id)) ?? null;
+}
+
+export interface NewMailWatch {
+ /** The local store's `created` event for this login's account. */
+ noteCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void;
+ /** Takes a state to work from, so the first push after the start can tell what's new. */
+ seed(client: IJMAPClient): void;
+ stop(): void;
+}
+
+export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWatch {
+ const baselines = new WeakMap>();
+ const seen: string[] = [];
+ let queue: Promise = Promise.resolve();
+ let stopped = false;
+
+ const baselinesOf = (client: IJMAPClient) => {
+ let map = baselines.get(client);
+ if (!map) {
+ map = new Map();
+ baselines.set(client, map);
+ }
+ return map;
+ };
+
+ const enqueue = (run: () => Promise) => {
+ const next = queue.then(run, run).catch((error) => {
+ console.error('Failed to check for new mail:', error);
+ });
+ queue = next;
+ return next;
+ };
+
+ const report = async (client: IJMAPClient, accountId: string, ids: readonly string[]) => {
+ const fresh = ids.filter((id) => !seen.includes(`${accountId}/${id}`));
+ if (fresh.length === 0 || stopped) return;
+ const [emails, mailboxes] = await Promise.all([client.getSomeEmails(fresh, accountId), client.getAllMailboxes()]);
+ // Only what came back counts as told. With the store on, a push can overtake it: the
+ // device doesn't have the message yet, and its created event has to be able to tell it.
+ for (const email of emails) seen.push(`${accountId}/${email.id}`);
+ seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT));
+ const own = client.getAccountId();
+ const mail = emails
+ .filter((email) => notifyingMailbox(email, accountId, own, mailboxes))
+ .map((email) => ({ client, accountId, email }));
+ if (mail.length > 0 && !stopped) onMail(mail);
+ };
+
+ const check = async (client: IJMAPClient, accountId: string, pushed: string) => {
+ const map = baselinesOf(client);
+ const since = map.get(accountId);
+ if (!since || !client.getEmailChanges) {
+ map.set(accountId, pushed);
+ return;
+ }
+ if (since === pushed) return;
+ const created: string[] = [];
+ let state = since;
+ for (let page = 0; page < MAX_PAGES; page += 1) {
+ const delta = await client.getEmailChanges(state, accountId, MAX_CHANGES);
+ if (!delta) {
+ // Too old for the server: start again from here.
+ map.set(accountId, pushed);
+ return;
+ }
+ created.push(...delta.created);
+ state = delta.newState;
+ if (!delta.hasMoreChanges) break;
+ }
+ map.set(accountId, state);
+ await report(client, accountId, created);
+ };
+
+ const stopPush = subscribePushStateChanges((change, client) => {
+ for (const [accountId, types] of Object.entries(change.changed ?? {})) {
+ const pushed = types?.Email;
+ if (pushed) void enqueue(() => check(client, accountId, pushed));
+ }
+ });
+
+ return {
+ noteCreated(client, accountId, ids) {
+ void enqueue(() => report(client, accountId, ids));
+ },
+ seed(client) {
+ void enqueue(async () => {
+ const map = baselinesOf(client);
+ const own = client.getAccountId();
+ const mailboxes = await client.getAllMailboxes();
+ const inboxes = mailboxes.filter((mailbox) => mailbox.role === 'inbox');
+ await Promise.all(inboxes.map(async (inbox) => {
+ const accountId = inbox.accountId ?? own;
+ if (map.has(accountId)) return;
+ const { state } = await client.getEmails(inbox.originalId || inbox.id, accountId, 1);
+ if (state && !map.has(accountId)) map.set(accountId, state);
+ }));
+ });
+ },
+ stop() {
+ stopped = true;
+ stopPush();
+ },
+ };
+}
diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts
new file mode 100644
index 000000000..ede5e140b
--- /dev/null
+++ b/lib/desktop/os-commands.ts
@@ -0,0 +1,192 @@
+/**
+ * What the OS asks of the app, carried out with what the app already has: the launch
+ * handoff for files and new messages, the protocol handlers for mailto: and webcal:,
+ * the notification actions for a toast's buttons. A bridge command comes from the
+ * user's own OS, so it skips the question a web share gets (decision 17).
+ */
+
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import type { DesktopOsCommand } from '@/lib/desktop/types';
+import { openMailLink } from '@/lib/desktop/mail-links';
+import { handleReminderCommand } from '@/lib/desktop/reminder-notifications';
+import type { MailNotificationContext } from '@/lib/desktop/mail-notifications';
+import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff';
+import { parseMailto } from '@/lib/protocol-handlers/mailto';
+import { notifyPendingMailto, notifyPendingWebcal, savePendingMailto, savePendingWebcal } from '@/lib/protocol-handlers/session';
+import { parseWebcal } from '@/lib/protocol-handlers/webcal';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { runNotificationAction, type NotificationActionRequest, type NotificationActionStatus } from '@/lib/pwa-notify/page-actions';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+
+export interface OsCommandEnv {
+ /** Goes to a page, saving an open composer first. */
+ leaveFor: (path: '/' | '/calendar' | '/contacts') => void | Promise;
+ /** A route change that isn't leaving anything behind. */
+ push: (path: string) => void;
+ /** Says something couldn't be opened. */
+ notifyFailed: (names: readonly string[]) => void;
+ /** A typed reply that has nowhere to go: its message's login is gone from here. */
+ notifyReplyLost: () => void;
+}
+
+/** How long a toast's button waits for a cold start to sign in. */
+const SIGN_IN_WAIT_MS = 30_000;
+
+function parseContext(raw: string | undefined): MailNotificationContext {
+ if (!raw) return {};
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return parsed && typeof parsed === 'object' ? parsed as MailNotificationContext : {};
+ } catch {
+ return {};
+ }
+}
+
+function whenSignedIn(): Promise {
+ return new Promise((resolve) => {
+ if (useAuthStore.getState().isAuthenticated) {
+ resolve(true);
+ return;
+ }
+ const finish = (value: boolean) => {
+ clearTimeout(timer);
+ stop();
+ resolve(value);
+ };
+ const timer = setTimeout(() => finish(false), SIGN_IN_WAIT_MS);
+ const stop = useAuthStore.subscribe((state) => {
+ if (state.isAuthenticated) finish(true);
+ });
+ });
+}
+
+/** A slot goes to whoever signs in next: a day-old toast has to still mean this login. */
+async function stillThatLogin(context: MailNotificationContext): Promise {
+ if (context.slot === undefined || !context.login) return false;
+ const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot);
+ return !!entry && (await loginFingerprint(entry.serverUrl, entry.username)) === context.login;
+}
+
+function showWindow(): void {
+ getDesktopBridge()?.windows?.focus?.();
+}
+
+async function openFromNotification(context: MailNotificationContext, env: OsCommandEnv, reply?: { text: string }): Promise {
+ showWindow();
+ await whenSignedIn();
+ const known = await stillThatLogin(context);
+ if (known && context.emailId) {
+ openMailLink({ kind: 'message', id: context.emailId, slot: context.slot, ownerAccountId: context.accountId, ...(reply ? { reply: true } : {}) }, reply ? { replyText: reply.text } : undefined, env.leaveFor);
+ return;
+ }
+ if (reply?.text) env.notifyReplyLost();
+ openMailLink({ kind: 'folder', ref: 'inbox', ...(known ? { slot: context.slot } : {}) }, undefined, env.leaveFor);
+}
+
+function runAction(request: NotificationActionRequest): Promise {
+ return new Promise((resolve) => {
+ void runNotificationAction(request, (status) => {
+ if (status !== 'accepted') resolve(status);
+ });
+ });
+}
+
+async function handleNotification(action: string, rawContext: string, reply: string | undefined, env: OsCommandEnv): Promise {
+ // An old toast's Delete doesn't act behind the lock, and its message doesn't open over it.
+ if (isAppLocked()) {
+ showWindow();
+ return;
+ }
+ if (await handleReminderCommand(action, rawContext, env)) return;
+ const context = parseContext(rawContext);
+ if (action === 'reply') {
+ await openFromNotification(context, env, { text: reply ?? '' });
+ return;
+ }
+ if ((action === 'archive' || action === 'mark-read' || action === 'delete') && context.emailId && context.accountId) {
+ if (await whenSignedIn()) {
+ const status = await runAction({
+ action,
+ emailId: context.emailId,
+ accountId: context.accountId,
+ threadId: context.threadId,
+ slot: context.slot,
+ login: context.login,
+ at: Date.now(),
+ });
+ if (status === 'done') return;
+ }
+ }
+ // Clicked, or the button couldn't be carried out here: the message opens.
+ await openFromNotification(context, env);
+}
+
+function handleFiles(command: Extract, env: OsCommandEnv): void {
+ const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name));
+ const failed = [...command.failed];
+ if (command.reason === 'attach') {
+ if (made.length > 0) handOffDraft({ files: made });
+ } else {
+ for (const file of made) {
+ const kind = kindForFile(file);
+ if (kind) handOffFiles([file], kind);
+ else failed.push(file.name);
+ }
+ }
+ if (failed.length > 0) env.notifyFailed(failed);
+}
+
+export function handleOsCommand(command: DesktopOsCommand, env: OsCommandEnv): void | Promise {
+ switch (command.type) {
+ case 'new-message':
+ handOffNewMessage();
+ return;
+ case 'navigate':
+ if (command.target === 'inbox') openMailLink({ kind: 'folder', ref: 'inbox' }, undefined, env.leaveFor);
+ else void env.leaveFor(`/${command.target}`);
+ return;
+ case 'open-message': {
+ const context = parseContext(command.context);
+ if (context.slot !== undefined) return openFromNotification({ ...context, emailId: command.emailId }, env);
+ openMailLink({ kind: 'message', id: command.emailId, ownerAccountId: command.accountId }, undefined, env.leaveFor);
+ return;
+ }
+ case 'open-mailbox':
+ openMailLink({ kind: 'folder', ref: command.mailboxId }, undefined, env.leaveFor);
+ return;
+ case 'mailto': {
+ const parsed = parseMailto(command.url);
+ if (!parsed) return;
+ savePendingMailto(parsed);
+ notifyPendingMailto();
+ env.push('/');
+ return;
+ }
+ case 'webcal': {
+ const parsed = parseWebcal(command.url);
+ if (!parsed) return;
+ savePendingWebcal(parsed);
+ notifyPendingWebcal();
+ env.push('/calendar');
+ return;
+ }
+ case 'files':
+ handleFiles(command, env);
+ return;
+ case 'notification':
+ return handleNotification(command.action, command.context, command.reply, env);
+ }
+}
+
+/** Listens for as long as the page lives. Commands wait in main until this runs. */
+export function startOsCommands(env: OsCommandEnv): () => void {
+ const os = getDesktopBridge()?.os;
+ if (!os) return () => {};
+ return os.onCommand((command) => {
+ void Promise.resolve(handleOsCommand(command, env)).catch((error) => {
+ console.error('An OS command failed:', error);
+ });
+ });
+}
diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts
new file mode 100644
index 000000000..599dd942a
--- /dev/null
+++ b/lib/desktop/os-summary.ts
@@ -0,0 +1,138 @@
+/**
+ * What the tray and the jump list show: recent unread mail, the newest conversations,
+ * the next calendar event. Read with the same client calls as the rest of the app, so
+ * the local store answers when it has them. No content while locked or where the
+ * organisation keeps it off notifications.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { contentHiddenFor } from '@/lib/desktop/mail-notifications';
+import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types';
+import type { CalendarEvent, Email } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useEmailStore } from '@/stores/email-store';
+import { usePolicyStore } from '@/stores/policy-store';
+
+const ROWS = 5;
+const REFRESH_DELAY_MS = 2000;
+const REFRESH_EVERY_MS = 5 * 60 * 1000;
+const EVENT_WINDOW_MS = 24 * 60 * 60 * 1000;
+
+export const EMPTY_SUMMARY: DesktopOsSummary = { unread: [], recent: [], nextEvent: null };
+
+export function eventStart(event: CalendarEvent): number {
+ return new Date(event.utcStart ?? event.start).getTime();
+}
+
+/** The soonest event still ahead, leaving out cancelled ones. */
+export function nextEventOf(events: readonly CalendarEvent[], now: number): CalendarEvent | null {
+ const ahead = events
+ .filter((event) => event.status !== 'cancelled' && !event.showWithoutTime && eventStart(event) >= now)
+ .sort((a, b) => eventStart(a) - eventStart(b));
+ return ahead[0] ?? null;
+}
+
+export function formatEventTime(start: number, now: number, locale: string | undefined): string {
+ const date = new Date(start);
+ const sameDay = date.toDateString() === new Date(now).toDateString();
+ const options: Intl.DateTimeFormatOptions = sameDay
+ ? { hour: 'numeric', minute: '2-digit' }
+ : { weekday: 'short', hour: 'numeric', minute: '2-digit' };
+ return new Intl.DateTimeFormat(locale, options).format(date);
+}
+
+export async function readSummary(locale: string | undefined, now = Date.now()): Promise {
+ const auth = useAuthStore.getState();
+ const client: IJMAPClient | null = auth.client;
+ const entry = useAccountStore.getState().accounts.find((account) => account.id === auth.activeAccountId);
+ if (!auth.isAuthenticated || !client || !entry || contentHiddenFor(entry.id)) return EMPTY_SUMMARY;
+
+ const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared);
+ let unread: DesktopOsTrayMessage[] = [];
+ let recent: DesktopOsTrayMessage[] = [];
+ if (inbox) {
+ const accountId = client.getAccountId();
+ const login = await loginFingerprint(entry.serverUrl, entry.username);
+ const row = (email: Email): DesktopOsTrayMessage => ({
+ emailId: email.id,
+ accountId,
+ sender: email.from?.[0]?.name || email.from?.[0]?.email || '',
+ subject: email.subject ?? '',
+ context: JSON.stringify({ emailId: email.id, threadId: email.threadId, accountId, slot: entry.cookieSlot, login }),
+ });
+ const [unreadList, recentList] = await Promise.all([
+ client.getEmails(inbox.originalId || inbox.id, undefined, ROWS, 0, undefined, false, { notKeyword: '$seen' }),
+ client.getEmails(inbox.originalId || inbox.id, undefined, ROWS),
+ ]);
+ unread = unreadList.emails.map(row);
+ recent = recentList.emails.map(row);
+ }
+
+ let nextEvent: DesktopOsSummary['nextEvent'] = null;
+ if (useCalendarStore.getState().supportsCalendar) {
+ const events = await client.queryCalendarEvents({
+ after: new Date(now).toISOString(),
+ before: new Date(now + EVENT_WINDOW_MS).toISOString(),
+ }).catch(() => [] as CalendarEvent[]);
+ const next = nextEventOf(events, now);
+ if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) };
+ }
+ return { unread, recent, nextEvent };
+}
+
+/** Keeps main's copy current: when the unread count moves, and now and then for the next event. */
+export function startOsSummary(locale: () => string | undefined): { refresh: () => void; stop: () => void } {
+ const os = getDesktopBridge()?.os;
+ if (!os) return { refresh: () => {}, stop: () => {} };
+ let timer: ReturnType | null = null;
+ /** A read under way when the account changed answers for the old one, and is dropped. */
+ let generation = 0;
+
+ const run = () => {
+ timer = null;
+ const mine = ++generation;
+ void readSummary(locale()).then((summary) => {
+ if (mine === generation) os.setSummary(summary);
+ }, (error) => {
+ console.warn('Could not read the tray summary:', error);
+ });
+ };
+ const refresh = () => {
+ timer ??= setTimeout(run, REFRESH_DELAY_MS);
+ };
+ const inboxUnread = () => useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared)?.unreadEmails ?? -1;
+ let lastUnread = inboxUnread();
+ const stopEmail = useEmailStore.subscribe(() => {
+ const current = inboxUnread();
+ if (current === lastUnread) return;
+ lastUnread = current;
+ refresh();
+ });
+ const stopAuth = useAuthStore.subscribe((state, previous) => {
+ if (state.activeAccountId === previous.activeAccountId && state.isAuthenticated === previous.isAuthenticated) return;
+ generation += 1;
+ if (!state.isAuthenticated) os.setSummary(EMPTY_SUMMARY);
+ refresh();
+ });
+ // An organisation turning sender and subject off goes for what's already showing, too.
+ const stopPolicy = usePolicyStore.subscribe((state, previous) => {
+ if (state.loaded !== previous.loaded || state.policy !== previous.policy) refresh();
+ });
+ const interval = setInterval(refresh, REFRESH_EVERY_MS);
+ refresh();
+ return {
+ refresh,
+ stop() {
+ generation += 1;
+ stopEmail();
+ stopAuth();
+ stopPolicy();
+ clearInterval(interval);
+ if (timer) clearTimeout(timer);
+ },
+ };
+}
diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts
index 126eaeb66..ee00aec1f 100644
--- a/lib/desktop/policy-schema.ts
+++ b/lib/desktop/policy-schema.ts
@@ -823,8 +823,8 @@ export const POLICY_CONSUMERS: Record = {
UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
AppLockRequired: { repo: 'webmail', file: 'lib/app-lock/policy.ts' },
AppLockTimeoutMinutes: { repo: 'webmail', file: 'lib/app-lock/policy.ts' },
- TelemetryAllowed: { pendingConsumer: 'step 7, telemetry consent' },
- CrashReportsAllowed: { pendingConsumer: 'step 7, telemetry consent' },
+ TelemetryAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
+ CrashReportsAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' },
AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' },
KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' },
diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts
new file mode 100644
index 000000000..1e36e52ae
--- /dev/null
+++ b/lib/desktop/power.ts
@@ -0,0 +1,58 @@
+/**
+ * Waking up without stale state. After sleep, an unlock or a network change, the app
+ * does what it does when a tab comes back into view: the JMAP client checks for state
+ * 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 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';
+
+/** A resume, an unlock and a network change come together, and each would mean a round of requests. */
+const WAKE_REPEAT_MS = 30_000;
+
+function wake(): void {
+ document.dispatchEvent(new Event('visibilitychange'));
+ // Woken up with no network: the browser's own event comes when there is one.
+ if (navigator.onLine !== false) window.dispatchEvent(new Event('online'));
+ const policy = usePolicyStore.getState();
+ if (policy.loaded && !policy.fetching) void policy.fetchPolicy();
+ runWakeHandlers();
+}
+
+export function startPowerWatch(): () => void {
+ const bridge = getDesktopBridge()?.os;
+ if (!bridge) return () => {};
+ let lastWake = 0;
+ const wakeOnce = () => {
+ const now = Date.now();
+ if (now - lastWake < WAKE_REPEAT_MS) return;
+ lastWake = now;
+ wake();
+ };
+ const stopPower = bridge.onPower((event) => {
+ if (event === 'resume' || event === 'unlock-screen') wakeOnce();
+ else if (event === 'suspend') runSleepHandlers();
+ });
+ // Wi-Fi to cable is a new network with no offline moment in between. This event also fires
+ // for every shift in its speed estimate, which isn't news. Where Chromium doesn't report the
+ // type, that leaves the stream's own ping check and the online event.
+ const connection = (navigator as unknown as { connection?: EventTarget & { type?: string } }).connection;
+ let lastType = connection?.type;
+ const onConnection = () => {
+ const type = connection?.type;
+ if (!type || type === lastType) return;
+ lastType = type;
+ wakeOnce();
+ };
+ connection?.addEventListener?.('change', onConnection);
+ return () => {
+ stopPower();
+ connection?.removeEventListener?.('change', onConnection);
+ };
+}
diff --git a/lib/desktop/reminder-notifications.ts b/lib/desktop/reminder-notifications.ts
new file mode 100644
index 000000000..f2840e58f
--- /dev/null
+++ b/lib/desktop/reminder-notifications.ts
@@ -0,0 +1,243 @@
+/**
+ * Calendar reminders as native notifications, and what their buttons do.
+ *
+ * A toast may be clicked a day later and after a restart, so what it hands back says
+ * which login and which reminder and no more: the rest comes from the store. Main keeps
+ * pause, quiet hours and the OS's Do Not Disturb. Nothing about the event goes to main at
+ * all while the app is locked, or when the organisation says so for that login
+ * (pushWithoutContent, the rule new mail follows).
+ */
+
+import { announceAcknowledged } from '@/lib/calendar-reminders/channel';
+import { pokeReminders, useMissedReminders } from '@/lib/calendar-reminders/state';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import type { DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types';
+import { contentHiddenFor } from '@/lib/desktop/mail-notifications';
+import { joinLinkFrom } from '@/lib/event-links';
+import { openExternalLink, resolveExternalLink } from '@/lib/plugin-hook-result';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { getStore } from '@/lib/store/client/api';
+import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+
+export const SNOOZE_MINUTES = [5, 10, 15] as const;
+const SNOOZE_PREFIX = 'snooze-';
+const JOIN = 'join';
+const OPEN = 'open';
+/** How many titles a summary names. */
+const SUMMARY_TITLES = 3;
+
+export interface ReminderLabels {
+ /** Says nothing about the event. */
+ generic: string;
+ /** "In 10 min · Personal". */
+ when: (alert: ReminderAlert) => string;
+ untitled: string;
+ snooze: (minutes: number) => string;
+ /** Five buttons in a row on a Windows toast: three "Snooze..." would all cut off. */
+ snoozeShort: (minutes: number) => string;
+ open: string;
+ join: string;
+ /** Join a link to no service this app knows: says where it goes. */
+ joinHost: (host: string) => string;
+ missedTitle: (count: number) => string;
+ missedMore: (count: number) => string;
+}
+
+export interface ReminderToastContext {
+ /** The reminder. */
+ r?: string;
+ /** The summary. */
+ rb?: number;
+ /** No store to ask: the click only goes to the calendar. */
+ cal?: boolean;
+ slot?: number;
+ login?: string;
+}
+
+async function loginOf(account: string): Promise> {
+ const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account);
+ if (!entry) return {};
+ return { slot: entry.cookieSlot, login: await loginFingerprint(entry.serverUrl, entry.username) };
+}
+
+export function joinLabel(alert: Pick, labels: Pick): string {
+ return alert.joinHost ? labels.joinHost(alert.joinHost) : labels.join;
+}
+
+function reminderId(alert: ReminderAlert): string {
+ // Main drops an id it saw in the last few minutes, and a snooze can be shorter.
+ return `reminder:${alert.key}${alert.snoozedUntil ? `:${alert.snoozedUntil}` : ''}`;
+}
+
+export async function buildReminderNotification(account: string, alert: ReminderAlert, labels: ReminderLabels, sound: boolean): Promise {
+ const base = { group: 'reminders', kind: 'reminder' as const, sound };
+ // Not even the uid. Two at the same moment are one "you have a reminder".
+ if (isAppLocked()) return { ...base, id: `reminder-locked:${alert.fireTimeMs}`, title: labels.generic, body: '', context: '' };
+ const context: ReminderToastContext = { r: alert.key, ...(await loginOf(account)) };
+ const short = getDesktopBridge()?.platform === 'win32';
+ const actions = [
+ ...(alert.joinUrl ? [{ id: JOIN, label: joinLabel(alert, labels) }] : []),
+ { id: OPEN, label: labels.open },
+ ...SNOOZE_MINUTES.map((minutes) => ({ id: `${SNOOZE_PREFIX}${minutes}`, label: short ? labels.snoozeShort(minutes) : labels.snooze(minutes) })),
+ ];
+ const hidden = contentHiddenFor(account);
+ return {
+ ...base,
+ id: reminderId(alert),
+ title: hidden ? labels.generic : alert.title || labels.untitled,
+ body: hidden ? '' : labels.when(alert),
+ context: JSON.stringify(context),
+ actions,
+ };
+}
+
+/** The first few titles of a summary, for a place too small for all of them. */
+export function missedBody(alerts: readonly ReminderAlert[], labels: Pick): string {
+ const named = alerts.slice(0, SUMMARY_TITLES).map((alert) => alert.title || labels.untitled).join(', ');
+ const more = alerts.length - SUMMARY_TITLES;
+ return more > 0 ? `${named}, ${labels.missedMore(more)}` : named;
+}
+
+export async function buildMissedNotification(account: string, batch: MissedBatch, labels: ReminderLabels): Promise {
+ const context: ReminderToastContext = { rb: batch.batch, ...(await loginOf(account)) };
+ return {
+ id: `reminder-missed:${batch.batch}`,
+ group: 'reminders',
+ kind: 'reminder',
+ title: labels.missedTitle(batch.alerts.length),
+ body: contentHiddenFor(account) ? '' : missedBody(batch.alerts, labels),
+ context: JSON.stringify(context),
+ };
+}
+
+export interface PlainReminder {
+ key: string;
+ fireTimeMs: number;
+ title: string;
+ body: string;
+ sound: boolean;
+}
+
+/**
+ * Without a store: says so, with nothing to snooze. A click goes to the calendar.
+ * Resolves to main's answer, which also says whether a sound is in order.
+ */
+export function notifyWithoutStore(reminder: PlainReminder, labels: { generic: string }, account: string | null): Promise {
+ const hidden = contentHiddenFor(account);
+ const request = getDesktopBridge()?.os?.notify({
+ // The key holds the event's id, and nothing about the event should get out now.
+ id: hidden ? `reminder-hidden:${reminder.fireTimeMs}` : `reminder:${reminder.key}`,
+ group: 'reminders',
+ kind: 'reminder',
+ title: hidden ? labels.generic : reminder.title,
+ body: hidden ? '' : reminder.body,
+ context: JSON.stringify({ cal: true } satisfies ReminderToastContext),
+ sound: reminder.sound,
+ });
+ return (request ?? Promise.resolve(undefined)).catch(() => undefined);
+}
+
+async function accountOf(context: ReminderToastContext): Promise {
+ if (context.slot === undefined || !context.login) return null;
+ const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot);
+ if (!entry || (await loginFingerprint(entry.serverUrl, entry.username)) !== context.login) return null;
+ return entry.id;
+}
+
+/** Goes to the event when it's in the account on screen, otherwise to the calendar. */
+export function calendarPathFor(account: string, alert: ReminderAlert | null): string | null {
+ if (!alert || alert.kind !== 'event' || useAuthStore.getState().activeAccountId !== account) return null;
+ const query = alert.calendarId ? `?calendar=${encodeURIComponent(alert.calendarId)}` : '';
+ return `/calendar/event/${encodeURIComponent(alert.baseEventId)}${query}`;
+}
+
+/**
+ * Opens a meeting the way every link in the app opens, past any link check a plugin has.
+ * Resolves true when it opened. Only web links get this far, whatever the store holds.
+ */
+export async function joinMeeting(url: string): Promise {
+ const link = joinLinkFrom(url);
+ if (!link) return false;
+ const shell = getDesktopBridge()?.shell;
+ if (!shell) return openExternalLink({ href: link.url });
+ const target = await resolveExternalLink({ href: link.url });
+ if (!target || !joinLinkFrom(target)) return false;
+ shell.openExternal(target);
+ return true;
+}
+
+export function dismissReminders(account: string, keys: string[]): void {
+ announceAcknowledged(keys);
+ void getStore().area(account, 'reminders.dismiss', { keys }).catch(() => {});
+}
+
+export async function snoozeReminder(account: string, key: string, minutes: number): Promise {
+ announceAcknowledged([key]);
+ await getStore().area(account, 'reminders.snooze', { key, minutes });
+ pokeReminders();
+}
+
+export interface ReminderCommandEnv {
+ leaveFor: (path: '/calendar') => void | Promise;
+ push: (path: string) => void;
+}
+
+function parse(raw: string): ReminderToastContext | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw) as ReminderToastContext;
+ return parsed && typeof parsed === 'object' && (parsed.r !== undefined || parsed.rb !== undefined || parsed.cal) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+function snoozeMinutes(action: string): number | null {
+ if (!action.startsWith(SNOOZE_PREFIX)) return null;
+ const minutes = Number(action.slice(SNOOZE_PREFIX.length));
+ return (SNOOZE_MINUTES as readonly number[]).includes(minutes) ? minutes : null;
+}
+
+/** Whether this click was a reminder's. Buttons that only do something don't bring the window up. */
+export async function handleReminderCommand(action: string, rawContext: string, env: ReminderCommandEnv): Promise {
+ const context = parse(rawContext);
+ if (!context) return false;
+ const account = await accountOf(context);
+ const show = () => getDesktopBridge()?.windows?.focus?.();
+
+ if (context.rb !== undefined) {
+ show();
+ const alerts = account ? await getStore().area(account, 'reminders.batch', { batch: context.rb }).catch(() => []) : [];
+ if (account && alerts.length > 0) useMissedReminders.getState().show(account, alerts);
+ else await env.leaveFor('/calendar');
+ return true;
+ }
+
+ const key = context.r;
+ const minutes = snoozeMinutes(action);
+ if (minutes !== null && account && key) {
+ await snoozeReminder(account, key, minutes);
+ return true;
+ }
+
+ const alert = account && key ? await getStore().area(account, 'reminders.get', { key }).catch(() => null) : null;
+ if (action === JOIN && alert?.joinUrl) {
+ if (await joinMeeting(alert.joinUrl)) {
+ dismissReminders(account!, [alert.key]);
+ } else {
+ show();
+ }
+ return true;
+ }
+
+ // Open, a click on the toast, or a button that can't be carried out any more.
+ show();
+ await env.leaveFor('/calendar');
+ const path = account ? calendarPathFor(account, alert) : null;
+ if (path) env.push(path);
+ if (account && alert) dismissReminders(account, [alert.key]);
+ return true;
+}
diff --git a/lib/desktop/server-links.ts b/lib/desktop/server-links.ts
new file mode 100644
index 000000000..25257222b
--- /dev/null
+++ b/lib/desktop/server-links.ts
@@ -0,0 +1,33 @@
+/**
+ * The two things main hands the embedded server while it runs. The server's host
+ * (src/server/host.ts in the desktop repo) has no way into Next's bundles but globalThis,
+ * so it calls what this leaves there. instrumentation.node.ts installs them, only in the
+ * desktop build.
+ */
+
+import { captureDesktopMainError, type DesktopMainErrorInput } from '@/lib/error-reporting/server';
+import { reschedule } from '@/lib/telemetry/sender';
+
+const PRIVACY_CHANGED = Symbol.for('bulwark.desktop.privacyChanged');
+const MAIN_ERRORS = Symbol.for('bulwark.desktop.mainErrors');
+const MAIN_ERRORS_READY = Symbol.for('bulwark.desktop.mainErrorsReady');
+
+type Holder = Record;
+
+function drainMainErrors(holder: Holder): void {
+ const queue = holder[MAIN_ERRORS];
+ if (!Array.isArray(queue)) return;
+ for (const error of queue.splice(0)) void captureDesktopMainError(error as DesktopMainErrorInput);
+}
+
+export function installDesktopServerLinks(): void {
+ const holder = globalThis as unknown as Holder;
+ // Turning telemetry on starts the heartbeat now, without a restart. Off needs no
+ // more than the value: sendOnce reads it before every send.
+ holder[PRIVACY_CHANGED] = () => {
+ void reschedule();
+ };
+ // Errors from before this ran are waiting in the queue.
+ holder[MAIN_ERRORS_READY] = () => drainMainErrors(holder);
+ drainMainErrors(holder);
+}
diff --git a/lib/desktop/server-privacy.ts b/lib/desktop/server-privacy.ts
new file mode 100644
index 000000000..df79316bb
--- /dev/null
+++ b/lib/desktop/server-privacy.ts
@@ -0,0 +1,19 @@
+/**
+ * The desktop user's telemetry and crash report consent, as the embedded server sees it.
+ *
+ * The Electron main process sends this at start and on every change (src/server/host.ts
+ * in the desktop repo sets it on globalThis, which all of Next's bundles share, like the
+ * list of trusted servers). Nothing in the renderer or the environment can write it.
+ *
+ * Only the desktop build reads it. No value means nobody has said yes: everything off.
+ */
+
+import { IS_DESKTOP_BUILD } from './flag';
+import type { DesktopServerPrivacy } from './desktop-telemetry';
+
+const PRIVACY_KEY = Symbol.for('bulwark.desktop.privacy');
+
+export function desktopServerPrivacy(): DesktopServerPrivacy | null {
+ if (!IS_DESKTOP_BUILD) return null;
+ return (globalThis as unknown as Record)[PRIVACY_KEY] ?? null;
+}
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/desktop/taskbar-progress.ts b/lib/desktop/taskbar-progress.ts
new file mode 100644
index 000000000..69098f37b
--- /dev/null
+++ b/lib/desktop/taskbar-progress.ts
@@ -0,0 +1,13 @@
+import { getDesktopBridge } from './bridge';
+
+type ProgressMode = 'normal' | 'indeterminate' | 'paused' | 'error';
+
+/**
+ * The taskbar's progress bar: 0 to 1, or null to take it away. Does nothing in a
+ * browser, or in a shell with no `os.setProgress`, which arrives with
+ * feat/os-integration. When that merges, its setTaskbarProgress can replace this.
+ */
+export function showTaskbarProgress(progress: number | null, mode?: ProgressMode): void {
+ const bridge = getDesktopBridge() as { os?: { setProgress?: (progress: number | null, mode?: ProgressMode) => void } } | null;
+ bridge?.os?.setProgress?.(progress, mode);
+}
diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts
index 65bdf21e4..98bf54fe6 100644
--- a/lib/desktop/types.ts
+++ b/lib/desktop/types.ts
@@ -315,6 +315,166 @@ export interface DesktopAppLockBridge {
onLockRequest: (listener: () => void) => () => void;
}
+export interface DesktopOsSettings {
+ /** Closing the main window leaves the app running in the tray. */
+ closeToTray: boolean;
+ startAtLogin: boolean;
+ /** At login: no window, only the tray. */
+ startMinimized: boolean;
+ /** Taskbar flash or dock bounce on new mail. */
+ attentionOnNewMail: boolean;
+ quietHoursEnabled: boolean;
+ /** HH:MM, local time. */
+ quietHoursStart: string;
+ quietHoursEnd: string;
+ /** "Show Bulwark", an Electron accelerator. Null: off. */
+ showShortcut: string | null;
+}
+
+export interface DesktopOsStatus {
+ settings: DesktopOsSettings;
+ pausedUntil: number | null;
+ /** The shortcut in settings is really held. Another app can have it. */
+ shortcutActive: boolean;
+}
+
+export interface DesktopOsFile {
+ name: string;
+ data: Uint8Array;
+}
+
+/** What the OS asks the app to do: a tray item, a jump list entry, a toast, a double-clicked file. */
+export type DesktopOsCommand =
+ | { type: 'new-message' }
+ | { type: 'navigate'; target: 'inbox' | 'calendar' | 'contacts' }
+ | { type: 'open-message'; emailId: string; accountId?: string; context?: string }
+ | { type: 'open-mailbox'; mailboxId: string }
+ | { type: 'mailto'; url: string }
+ | { type: 'webcal'; url: string }
+ | { type: 'files'; reason: 'open' | 'attach'; files: DesktopOsFile[]; failed: string[] }
+ | { type: 'notification'; action: string; context: string; reply?: string };
+
+export interface DesktopOsNotification {
+ /** Tag of the toast. Showing an id again within a few minutes does nothing. */
+ id: string;
+ group?: string;
+ title: string;
+ body: string;
+ /** Handed back with the action. Keep it short: Windows keeps it in the toast. */
+ context: string;
+ actions?: { id: string; label: string }[];
+ reply?: { label: string; placeholder: string };
+ /** The user wants a sound with this. */
+ sound?: boolean;
+ /** New mail: flash the taskbar or bounce the dock if that's on. */
+ attention?: boolean;
+ /** What a locked app says instead: new mail unless this says otherwise. */
+ kind?: 'mail' | 'reminder';
+}
+
+export interface DesktopOsNotifyResult {
+ shown: boolean;
+ reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid' | 'failed';
+ /** The page should play the sound the user picked. */
+ playSound: boolean;
+}
+
+export interface DesktopOsTrayMessage {
+ emailId: string;
+ accountId: string;
+ sender: string;
+ subject: string;
+ context?: string;
+}
+
+export interface DesktopOsSummary {
+ unread: DesktopOsTrayMessage[];
+ /** Newest conversations, whether read or not, for the jump list. */
+ recent: DesktopOsTrayMessage[];
+ nextEvent: { title: string; when: string } | null;
+}
+
+export type DesktopOsPowerEvent = 'suspend' | 'resume' | 'lock-screen' | 'unlock-screen';
+
+export interface DesktopOsDefaultAppStatus {
+ /** Offered to the OS as a mail app. */
+ registered: boolean;
+ mailto: boolean;
+ webcal: boolean;
+}
+
+export interface DesktopOsBridge {
+ /**
+ * What the OS asks for. Main holds these until the main window's page subscribes,
+ * so a cold start loses nothing.
+ */
+ onCommand: (listener: (command: DesktopOsCommand) => void) => () => void;
+ getStatus: () => Promise;
+ setSettings: (patch: Partial) => Promise;
+ /** Pause notifications until this time, or null to end the pause. */
+ setPaused: (until: number | null) => void;
+ onStatusChange: (listener: (status: DesktopOsStatus) => void) => () => void;
+ /** The unread count: overlay on Windows, setBadgeCount on macOS and Linux, the tray's dot. */
+ setBadge: (count: number) => void;
+ /** What the tray and the jump list show. */
+ setSummary: (summary: DesktopOsSummary) => void;
+ /** The taskbar's progress bar: lib/desktop/file-drag.ts setTaskbarProgress. */
+ setProgress: (progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error') => void;
+ /**
+ * App lock's state. Main counts as locked until the main window's page says otherwise,
+ * and clears the tray, jump list and toasts and strips what comes through.
+ */
+ setLocked: (locked: boolean) => void;
+ /** Sign-out or a wipe: delivered toasts, badge, tray and jump list go, and temporary files. */
+ clearSurfaces: () => void;
+ notify: (notification: DesktopOsNotification) => Promise;
+ onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void;
+ /** Writes the files to a private folder and starts a drag. Only with the mouse held. */
+ startDrag: (files: DesktopOsFile[]) => Promise;
+ /** The same, onto the clipboard as files. */
+ copyFiles: (files: DesktopOsFile[]) => Promise;
+ defaultAppStatus: () => Promise;
+ /** Offers the app to the OS and opens where the user chooses. */
+ openDefaultAppSettings: () => Promise;
+}
+
+export interface DesktopPrivacyState {
+ /** `forcedOff`: a policy says no, so `on` is false and the user can't change it. */
+ telemetry: { on: boolean; forcedOff: boolean };
+ /** `uploadUrl` is where "Send crash report" goes. Empty: nowhere, and the action says so. */
+ crashReports: { on: boolean; forcedOff: boolean; uploadUrl: string };
+}
+
+export interface DesktopCrashFile {
+ /** Opaque: the only values `sendCrash` accepts. */
+ id: string;
+ /** Milliseconds since 1970. */
+ createdAt: number;
+ bytes: number;
+}
+
+export type DesktopCrashSendResult =
+ | { ok: true }
+ | { ok: false; reason: 'no-consent' | 'no-url' | 'not-found' | 'cancelled' | 'failed' };
+
+/**
+ * Telemetry and crash reports, each with its own consent, both off until the user says
+ * yes. A same-origin plugin can reach this, so saying yes and sending a crash file both
+ * ask in a native dialog that names where it goes.
+ */
+export interface DesktopPrivacyBridge {
+ get: () => Promise;
+ setTelemetry: (on: boolean) => Promise;
+ setCrashReports: (on: boolean) => Promise;
+ /** https, or http on this computer. Empty clears it. */
+ setCrashUploadUrl: (url: string) => Promise<{ ok: boolean; state: DesktopPrivacyState }>;
+ listCrashes: () => Promise;
+ sendCrash: (id: string) => Promise;
+ deleteCrashes: () => Promise;
+ /** Also fires when a policy changes. Returns the unsubscribe. */
+ onChange: (listener: (state: DesktopPrivacyState) => void) => () => void;
+}
+
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
@@ -326,12 +486,14 @@ export interface BulwarkDesktopBridge {
dialogs?: DesktopDialogsBridge;
clipboard?: DesktopClipboardBridge;
lifecycle?: DesktopLifecycleBridge;
+ os?: DesktopOsBridge;
policy?: DesktopPolicyBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
keystore?: DesktopKeystoreBridge;
appLock?: DesktopAppLockBridge;
+ privacy?: DesktopPrivacyBridge;
}
declare global {
diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts
new file mode 100644
index 000000000..63a67630b
--- /dev/null
+++ b/lib/desktop/window-files.ts
@@ -0,0 +1,95 @@
+/**
+ * Files dropped on a window or pasted from the clipboard. Whatever already handles a
+ * drop keeps doing so, which it shows by preventDefault: the composer, the file page,
+ * calendar import. The rest becomes an attachment on a new draft, or on the composer
+ * that's open.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { handOffDraft } from '@/lib/launch-handoff';
+
+interface ComposerTarget {
+ take: (files: File[]) => unknown;
+ /** The Pro shell keeps composers mounted on tabs out of sight. */
+ visible: () => boolean;
+}
+
+const composerTargets: ComposerTarget[] = [];
+/** Our own native drag passing over our own window isn't a new file. */
+let ownDrag = false;
+
+/** A composer on screen takes files itself. No effect outside the desktop app: only this file reads it. */
+export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void {
+ const target = { take, visible };
+ composerTargets.push(target);
+ return () => {
+ const at = composerTargets.indexOf(target);
+ if (at !== -1) composerTargets.splice(at, 1);
+ };
+}
+
+function visibleComposer(): ComposerTarget | undefined {
+ return [...composerTargets].reverse().find((target) => target.visible());
+}
+
+export function setOwnDragActive(active: boolean): void {
+ ownDrag = active;
+}
+
+function take(files: File[]): void {
+ const composer = visibleComposer();
+ if (composer) composer.take(files);
+ else handOffDraft({ files });
+}
+
+function isEditable(target: EventTarget | null): boolean {
+ const element = target as Partial & { closest?: (selector: string) => Element | null } | null;
+ return !!element?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]');
+}
+
+/**
+ * Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a
+ * picture of them, and a field that takes text wants the text. The one editable place a
+ * bare file belongs in is the composer it's being pasted into.
+ */
+function pasteIsForUs(event: ClipboardEvent): boolean {
+ const types = [...(event.clipboardData?.types ?? [])];
+ if (types.includes('text/plain') || types.includes('text/html')) return false;
+ if (!isEditable(event.target)) return true;
+ const inComposer = !!(event.target as Partial | null)?.closest?.('[data-testid="email-composer"]');
+ return inComposer && !!visibleComposer();
+}
+
+function hasFiles(event: DragEvent): boolean {
+ return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files');
+}
+
+export function startWindowFiles(): () => void {
+ if (!getDesktopBridge()?.os) return () => {};
+ const onDragOver = (event: DragEvent) => {
+ if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
+ };
+ const onDrop = (event: DragEvent) => {
+ if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
+ event.preventDefault();
+ const files = Array.from(event.dataTransfer?.files ?? []);
+ if (files.length > 0) take(files);
+ };
+ const onPaste = (event: ClipboardEvent) => {
+ if (event.defaultPrevented) return;
+ const files = Array.from(event.clipboardData?.files ?? []);
+ if (files.length === 0 || !pasteIsForUs(event)) return;
+ event.preventDefault();
+ take(files);
+ };
+ window.addEventListener('dragover', onDragOver);
+ window.addEventListener('drop', onDrop);
+ window.addEventListener('paste', onPaste);
+ return () => {
+ window.removeEventListener('dragover', onDragOver);
+ window.removeEventListener('drop', onDrop);
+ window.removeEventListener('paste', onPaste);
+ };
+}
diff --git a/lib/devices/registry.ts b/lib/devices/registry.ts
index c69d95813..a826ddd23 100644
--- a/lib/devices/registry.ts
+++ b/lib/devices/registry.ts
@@ -19,6 +19,12 @@ import { settingsId } from '@/lib/settings-sync';
import { getStore, type CollectionOptions } from '@/lib/shared-store';
import type { DeviceView, WipeReason } from './types';
+/**
+ * A new row is written to the state dir and stays half a year, for anyone with a
+ * session. Enough for an office turning this on in one morning: a device turned
+ * away tries again in a quarter of an hour.
+ */
+export const REGISTER_PER_HOUR = 60;
export const DEVICE_ID_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}$/;
export const DEVICE_SECRET_RE = /^[0-9a-f]{64}$/;
const USER_KEY_RE = /^[0-9a-f]{64}$/;
diff --git a/lib/devices/types.ts b/lib/devices/types.ts
index 367b6bef7..f4e5facf7 100644
--- a/lib/devices/types.ts
+++ b/lib/devices/types.ts
@@ -3,8 +3,12 @@
* admin and what a device says about its own wipe. No secrets, no store.
*/
-/** Who asked for a wipe. `rejected`: the server refused the stored login for good. */
-export const WIPE_REASONS = ['admin', 'user', 'rejected'] as const;
+/**
+ * Who asked for a wipe. `rejected`: the server refused the stored login, and
+ * `session`: the idle or session limit ran out, both where the organisation asks
+ * for deletion at once.
+ */
+export const WIPE_REASONS = ['admin', 'user', 'rejected', 'session'] as const;
export type WipeReason = (typeof WIPE_REASONS)[number];
export interface DeviceView {
diff --git a/lib/devices/wipe-state.ts b/lib/devices/wipe-state.ts
index 2b8d9b749..5e4e97f82 100644
--- a/lib/devices/wipe-state.ts
+++ b/lib/devices/wipe-state.ts
@@ -4,6 +4,7 @@
* is a count, a row id and the secret for that one row.
*/
+import type { ForcedSignOutKind } from '@/lib/offline/credentials-rejected';
import { EVICTED_KEEP_MS, readEvicted } from '@/lib/offline/state';
import type { WipeReason } from './types';
@@ -107,7 +108,7 @@ export function consumeWipeNote(): WipeNote | null {
return note.unknown ? { unsent: note.unsent, unknown: true } : { unsent: note.unsent };
}
-// ---- A refused login: what waits for the new password. A count, never subjects ----
+// ---- A login ended with nobody asking: what waits for the next sign-in. A count, never subjects ----
export interface RefusedLogin {
at: number;
@@ -115,6 +116,23 @@ export interface RefusedLogin {
unsent: number | null;
/** Not one bad answer at a start: what a deletion the organisation asked for needs. */
certain: boolean;
+ /** Absent: `refused`. */
+ kind?: ForcedSignOutKind;
+ /** The count has been asked for, whatever came back. */
+ counted?: boolean;
+ /** Accounts signed in beside it at the time: they may see its name, and nobody else does. */
+ seenBy?: string[];
+ label?: string;
+ username?: string;
+ toasted?: boolean;
+}
+
+export interface RefusedLoginMark {
+ certain?: boolean;
+ kind?: ForcedSignOutKind;
+ seenBy?: string[];
+ label?: string;
+ username?: string;
}
export function readRefusedLogins(): Record {
@@ -134,14 +152,24 @@ function writeRefused(refused: Record): void {
for (const listener of [...refusedListeners]) listener();
}
-export function markLoginRefused(account: string, certain = true, now = Date.now()): void {
+export function markLoginRefused(account: string, mark: RefusedLoginMark = {}, now = Date.now()): void {
const before = readRefusedLogins();
- writeRefused({ ...before, [account]: { at: before[account]?.at ?? now, unsent: null, certain: certain || before[account]?.certain === true } });
+ const { certain = true, kind = 'refused', seenBy, label, username } = mark;
+ writeRefused({
+ ...before,
+ [account]: {
+ at: before[account]?.at ?? now,
+ unsent: null,
+ certain: certain || before[account]?.certain === true,
+ kind,
+ ...(seenBy?.length ? { seenBy, label, username } : {}),
+ },
+ });
}
export function setRefusedCount(account: string, unsent: number | null): void {
const before = readRefusedLogins();
- if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent } });
+ if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent, counted: true } });
}
export function dropRefusedLogin(account: string): void {
@@ -155,6 +183,7 @@ export interface RefusedWait {
unsent: number | null;
/** When the data goes if nobody signs in. */
until: number;
+ kind: ForcedSignOutKind;
}
/** What the sign-in page says. Numbers only: whoever reads it may not be the person it's about. */
@@ -162,7 +191,51 @@ export function refusedWaits(): RefusedWait[] {
const evicted = readEvicted();
return Object.entries(readRefusedLogins())
.filter(([account, wait]) => account in evicted && wait.unsent !== 0)
- .map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS }));
+ .map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS, kind: wait.kind ?? 'refused' }));
+}
+
+export interface SiblingWait extends RefusedWait {
+ account: string;
+ label: string;
+ username: string;
+}
+
+function asSiblingWait(account: string, wait: RefusedLogin, evictedAt: number): SiblingWait {
+ return {
+ account,
+ label: wait.label ?? account,
+ username: wait.username ?? '',
+ unsent: wait.unsent,
+ until: evictedAt + EVICTED_KEEP_MS,
+ kind: wait.kind ?? 'refused',
+ };
+}
+
+/** For the account switcher: accounts that left while `active` was signed in beside them. Nobody else sees their names. */
+export function siblingWaits(active: string | null | undefined): SiblingWait[] {
+ if (!active) return [];
+ const evicted = readEvicted();
+ return Object.entries(readRefusedLogins())
+ .filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active))
+ .map(([account, wait]) => asSiblingWait(account, wait, evicted[account]));
+}
+
+/** Once each: what the toast says when such an account leaves. Waits for the count, so it can say it. */
+export function takeSiblingToasts(active: string | null | undefined): SiblingWait[] {
+ if (!active) return [];
+ const evicted = readEvicted();
+ const refused = readRefusedLogins();
+ const due = Object.entries(refused)
+ .filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active) && wait.counted && !wait.toasted);
+ if (due.length === 0) return [];
+ writeRefused({ ...refused, ...Object.fromEntries(due.map(([account, wait]) => [account, { ...wait, toasted: true }])) });
+ return due.map(([account, wait]) => asSiblingWait(account, wait, evicted[account]));
+}
+
+/** A date for these notes, in the page's language. */
+export function waitDate(until: number): string {
+ const language = typeof document === 'undefined' ? undefined : document.documentElement.lang || undefined;
+ return new Date(until).toLocaleDateString(language, { dateStyle: 'medium' });
}
// ---- Plugin databases a running frame kept open: tried again at the next load ----
diff --git a/lib/error-reporting/__tests__/desktop-main-errors.test.ts b/lib/error-reporting/__tests__/desktop-main-errors.test.ts
new file mode 100644
index 000000000..af36d0763
--- /dev/null
+++ b/lib/error-reporting/__tests__/desktop-main-errors.test.ts
@@ -0,0 +1,86 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Main's errors on their way through the embedded server: handleServerReport counts them
+// them. The webmail scrubs them, so a message off the disk or
+// out of a mail can't get through.
+
+const flag = vi.hoisted(() => ({ IS_DESKTOP_BUILD: true, dsn: '' }));
+vi.mock('@/lib/desktop/flag', () => ({
+ get IS_DESKTOP_BUILD() {
+ return flag.IS_DESKTOP_BUILD;
+ },
+}));
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: async () => {},
+ get: (key: string, fallback: unknown) => (key === 'errorReportingDsn' ? flag.dsn : fallback),
+ },
+}));
+vi.mock('@/lib/metrics', () => ({ metricsEnabled: () => false }));
+const consent = vi.hoisted(() => ({ value: 'instant' as 'instant' | 'off' }));
+vi.mock('@/lib/telemetry/state', () => ({
+ effectiveErrorsConsent: async () => ({ consent: consent.value, source: 'env' }),
+ telemetryDocs: () => ({ update: async () => {}, get: async () => null }),
+}));
+
+import { readErrors, resetAggregateForTests, utcDay } from '../aggregate';
+import { captureDesktopMainError } from '../server';
+
+const MAIN = {
+ name: 'TypeError',
+ message: "ENOENT: open 'C:\\Users\\linus\\AppData\\Roaming\\Bulwark\\inbox.json' for ceo.private@acme-corp.example",
+ stack: 'TypeError: secret\n at sendPing (/desktop/main.mjs:120:7)\n at file:///C:/Users/linus/elsewhere/other.js:1:1',
+ mechanism: 'uncaughtexception',
+};
+
+let fetchSpy: ReturnType;
+
+beforeEach(() => {
+ resetAggregateForTests();
+ // The server's limiter sends one per fingerprint per hour.
+ delete (globalThis as unknown as Record)[Symbol.for('bulwark.error-reporting.server')];
+ consent.value = 'instant';
+ flag.IS_DESKTOP_BUILD = true;
+ // The frames' rule is compiled into this build only.
+ vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
+ flag.dsn = '';
+ fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
+ vi.stubGlobal('fetch', fetchSpy);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+});
+
+async function counted() {
+ const day = (await readErrors())[utcDay()] ?? {};
+ return Object.values(day);
+}
+
+describe('an error from the main process', () => {
+ it('is counted with its frames kept and the disk gone', async () => {
+ await captureDesktopMainError(MAIN);
+ const [entry] = await counted();
+ expect(entry.name).toBe('TypeError');
+ expect(entry.frames[0]).toMatchObject({ path: '/desktop/main.mjs', line: 120, fn: 'sendPing' });
+ expect(entry.frames[1].path).toBe('[other]');
+ expect(JSON.stringify(entry)).not.toContain('linus');
+ expect(JSON.stringify(entry)).not.toContain('acme-corp');
+ });
+
+ it('goes nowhere without consent', async () => {
+ consent.value = 'off';
+ flag.dsn = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
+ await captureDesktopMainError(MAIN);
+ expect(await counted()).toEqual([]);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('never throws, whatever it is given', async () => {
+ await expect(captureDesktopMainError({ name: 5, message: {}, stack: 7 })).resolves.toBeUndefined();
+ const [entry] = await counted();
+ expect(entry.name).toBe('Error');
+ });
+});
diff --git a/lib/error-reporting/__tests__/desktop-reports.test.ts b/lib/error-reporting/__tests__/desktop-reports.test.ts
new file mode 100644
index 000000000..fe46d68f9
--- /dev/null
+++ b/lib/error-reporting/__tests__/desktop-reports.test.ts
@@ -0,0 +1,235 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ captureException, configureErrorReporting, reportFromError, resetErrorReportingForTests,
+} from '../client';
+import { appVersion } from '../envelope';
+import { scrubFramePath, scrubText } from '../scrub';
+import { parseStack, sanitizeFrames } from '../stack';
+
+// The desktop app's errors. Main's frames are the shell's own files as /desktop/...: they
+// have to survive the check that drops reports with no frame of ours, and nothing about the
+// user's disk may get through with them.
+
+const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
+const SETTINGS = { dsn: DSN, sampleRate: 1, environment: 'production', collect: false };
+
+const MAIN_STACK = [
+ "TypeError: Cannot read properties of undefined (reading 'x')",
+ ' at sendPing (/desktop/main.mjs:120:7)',
+ ' at async /desktop/main.mjs:90:3',
+ ' at processTicksAndRejections (node:internal/process/task_queues:105:5)',
+].join('\n');
+
+const EXTENSION_STACK = 'TypeError: nope\n at x (chrome-extension://abc/content.js:1:2)\n at y (moz-extension://def/inject.js:3:4)';
+
+function withStack(stack: string): Error {
+ const error = new TypeError('nope');
+ error.stack = stack;
+ return error;
+}
+
+function setBridge(bridge: unknown): void {
+ (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = bridge;
+}
+
+const forcedOff = { value: false, locked: true, source: 'machine' };
+
+describe('main-process frames', () => {
+ beforeEach(() => {
+ // The rule is compiled into the desktop build only.
+ vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
+ });
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('keeps a report whose frames are the shell\'s', () => {
+ const report = reportFromError(withStack(MAIN_STACK), 'desktop-main', false);
+ expect(report).not.toBeNull();
+ expect(report!.frames[0]).toMatchObject({ path: '/desktop/main.mjs', line: 120, column: 7, fn: 'sendPing' });
+ expect(report!.frames[1].path).toBe('/desktop/main.mjs');
+ expect(report!.frames[2].path).toBe('node:internal/process/task_queues');
+ });
+
+ it('keeps one where every frame is the shell\'s', () => {
+ const only = 'TypeError: nope\n at a (/desktop/main.mjs:1:2)\n at b (/desktop/main.mjs:3:4)';
+ expect(reportFromError(withStack(only), 'desktop-main', false)?.frames).toHaveLength(2);
+ });
+
+ it('still drops a report from a browser extension', () => {
+ expect(reportFromError(withStack(EXTENSION_STACK), 'onerror', false)).toBeNull();
+ });
+
+ it('survives being posted and scrubbed again', () => {
+ const frames = parseStack(MAIN_STACK);
+ expect(sanitizeFrames(frames).map((frame) => frame.path)).toEqual(frames.map((frame) => frame.path));
+ });
+
+ it.each([
+ 'C:\\Users\\linus\\AppData\\Local\\Programs\\Bulwark\\resources\\app.asar\\dist\\main.mjs',
+ 'file:///C:/Users/linus/AppData/Local/Programs/Bulwark/resources/app.asar/dist/main.mjs',
+ '/home/linus/.local/bin/bulwark/resources/app.asar/dist/main.mjs',
+ '/desktop/../../Users/linus/secret.js',
+ '/desktop/C:\\Users\\linus\\x.js',
+ '/desktop/linus smith/x.js',
+ 'https://evil.example/desktop/x.js',
+ ])('gives nothing away for %s', (input) => {
+ expect(scrubFramePath(input)).toBe('[other]');
+ });
+});
+
+describe('a server install', () => {
+ it('sees no difference: a /desktop/ path is just another path', () => {
+ expect(scrubFramePath('/desktop/main.mjs')).toBe('[other]');
+ expect(parseStack('TypeError: x\n at async /desktop/main.mjs:90:3')[0].path).toBe('[other]');
+ expect(reportFromError(withStack('TypeError: x\n at a (/desktop/main.mjs:1:2)'), 'onerror', false)).toBeNull();
+ });
+
+ it('keeps reading an anonymous async frame as it did', () => {
+ // scrubFunctionName and the marker search see the same text as before this existed.
+ const [frame] = parseStack('TypeError: x\n at async https://mail.example.org/_next/static/chunks/1.js:2:30');
+ expect(frame.path).toBe('/_next/static/chunks/1.js');
+ expect(parseStack('TypeError: x\n at async node:internal/x:1:2')[0].path).toBe('[other]');
+ });
+});
+
+describe('what a report says', () => {
+ it.each([
+ ["ENOENT: no such file or directory, open 'C:\\Users\\linus\\AppData\\Roaming\\Bulwark\\crash.dmp'", ['linus', 'AppData', 'crash']],
+ ['EPERM: operation not permitted, mkdir C:\\Users\\linus\\mail', ['linus']],
+ ['failed to read /home/linus/.config/Bulwark/state', ['linus', '.config']],
+ ['Subject Merger plans for Acme failed from ceo.private@acme-corp.example', ['Merger', 'Acme', 'ceo.private', 'acme-corp']],
+ ['request failed Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdef sealed', ['eyJ', 'abcdef']],
+ ['token 0123456789abcdef0123456789abcdef0123 refused', ['0123456789']],
+ ])('%s', (input, gone) => {
+ const out = scrubText(input);
+ for (const word of gone) expect(out).not.toContain(word);
+ });
+});
+
+describe('tagged as the desktop app', () => {
+ let fetchSpy: ReturnType;
+
+ const eventOf = (init: RequestInit) => JSON.parse(String(init.body).trim().split('\n')[2]) as { tags: Record };
+
+ beforeEach(() => {
+ resetErrorReportingForTests();
+ sessionStorage.clear();
+ fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
+ vi.stubGlobal('fetch', fetchSpy);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ setBridge(undefined);
+ });
+
+ const bundleError = () => withStack('TypeError: nope\n at a (https://mail.example.org/_next/static/chunks/1.js:2:30)');
+
+ it('in the app, on the event that goes to a DSN', () => {
+ setBridge({ version: 1, platform: 'win32' });
+ configureErrorReporting(SETTINGS);
+ captureException(bundleError());
+ expect(eventOf(fetchSpy.mock.calls[0][1]).tags).toMatchObject({ runtime: 'browser', platform: 'desktop' });
+ });
+
+ it('not in a browser', () => {
+ configureErrorReporting(SETTINGS);
+ captureException(bundleError());
+ expect(eventOf(fetchSpy.mock.calls[0][1]).tags.platform).toBeUndefined();
+ });
+
+ it('a page in the app sends nothing with crash reports forced off, even to an instance that asks', () => {
+ setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: forcedOff }), onChange: () => () => {} } });
+ configureErrorReporting({ ...SETTINGS, collect: true });
+ captureException(bundleError());
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('a policy that only recommends leaves the instance\'s own settings in charge', () => {
+ setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: { value: false, locked: false, source: 'recommended' } }), onChange: () => () => {} } });
+ configureErrorReporting(SETTINGS);
+ captureException(bundleError());
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ describe('consent taken back with a page open', () => {
+ function privacyBridge(on: boolean) {
+ let listener: ((state: unknown) => void) | undefined;
+ const say = (next: boolean, forcedOff = false) => listener?.({ telemetry: { on: false, forcedOff: false }, crashReports: { on: next, forcedOff, uploadUrl: '' } });
+ setBridge({
+ version: 1,
+ platform: 'win32',
+ mode: 'instance',
+ privacy: {
+ get: async () => ({ telemetry: { on: false, forcedOff: false }, crashReports: { on, forcedOff: false, uploadUrl: '' } }),
+ onChange: (next: (state: unknown) => void) => {
+ listener = next;
+ return () => {};
+ },
+ },
+ });
+ return say;
+ }
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+ it('stops an open page sending to a DSN', async () => {
+ const say = privacyBridge(true);
+ configureErrorReporting(SETTINGS);
+ await settle();
+ captureException(bundleError());
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ say(false);
+ captureException(withStack('TypeError: other\n at b (https://mail.example.org/_next/static/chunks/2.js:2:30)'));
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('and a config arriving afterwards doesn\'t bring it back', async () => {
+ const say = privacyBridge(true);
+ configureErrorReporting(SETTINGS);
+ await settle();
+ say(false);
+ configureErrorReporting(SETTINGS);
+ captureException(bundleError());
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('a change to something else, with crash reports never on, leaves an instance\'s own settings alone', async () => {
+ const say = privacyBridge(false);
+ configureErrorReporting(SETTINGS);
+ await settle();
+ say(false);
+ captureException(bundleError());
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('a policy forcing it off stops it, however it stood', async () => {
+ const say = privacyBridge(false);
+ configureErrorReporting(SETTINGS);
+ await settle();
+ say(false, true);
+ captureException(bundleError());
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('saying yes again lifts it', async () => {
+ const say = privacyBridge(true);
+ configureErrorReporting(SETTINGS);
+ await settle();
+ say(false);
+ say(true);
+ configureErrorReporting(SETTINGS);
+ captureException(bundleError());
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('in the version, in the desktop build only: the collector has no room for a tag', () => {
+ vi.stubEnv('NEXT_PUBLIC_APP_VERSION', '1.20.0');
+ expect(appVersion()).toBe('1.20.0');
+ vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
+ expect(appVersion()).toBe('1.20.0+desktop');
+ expect(appVersion()).toMatch(/^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/);
+ });
+});
diff --git a/lib/error-reporting/client.ts b/lib/error-reporting/client.ts
index 2c842fae3..a11237a08 100644
--- a/lib/error-reporting/client.ts
+++ b/lib/error-reporting/client.ts
@@ -1,4 +1,5 @@
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
import { scrubRoutePath } from '@/lib/route-groups';
import { debugIdsByPath } from './debug-ids';
import {
@@ -41,6 +42,10 @@ interface ClientState {
breadcrumbs: Breadcrumb[];
limiter: ReportLimiter | null;
installed: boolean;
+ /** In the desktop app: the shell's privacy changes are being listened to. */
+ watchingConsent: boolean;
+ /** The user took crash reports back, or a policy did. A later config doesn't undo that. */
+ withdrawn: boolean;
}
// On globalThis: the error boundary, the JMAP client and the root component
@@ -48,7 +53,7 @@ interface ClientState {
const KEY = Symbol.for('bulwark.error-reporting.client');
function state(): ClientState {
const g = globalThis as typeof globalThis & { [KEY]?: ClientState };
- return (g[KEY] ??= { settings: undefined, pending: [], breadcrumbs: [], limiter: null, installed: false });
+ return (g[KEY] ??= { settings: undefined, pending: [], breadcrumbs: [], limiter: null, installed: false, watchingConsent: false, withdrawn: false });
}
function sessionStore(): Storage | null {
@@ -73,10 +78,36 @@ function currentRoute(): string | undefined {
return scrubRoutePath(location.pathname, basePath());
}
+/**
+ * Consent taken back while a page is open, in the desktop app. A change to anything else
+ * with crash reports never on leaves an instance's own settings alone: its admin chose
+ * those, and the settings page says so.
+ */
+function watchDesktopConsent(): void {
+ const s = state();
+ const bridge = getDesktopBridge();
+ if (s.watchingConsent || !bridge?.privacy?.onChange) return;
+ s.watchingConsent = true;
+ let on: boolean | null = null;
+ void Promise.resolve(bridge.privacy.get?.()).then((current) => {
+ on ??= current?.crashReports.on ?? null;
+ }).catch(() => {});
+ bridge.privacy.onChange((next) => {
+ const tookBack = on === true && !next.crashReports.on;
+ on = next.crashReports.on;
+ if (on) s.withdrawn = false;
+ if (tookBack || next.crashReports.forcedOff) {
+ s.withdrawn = true;
+ configureErrorReporting(null);
+ }
+ });
+}
+
/** Apply the settings from /api/config (or Lite's policy.json); null switches reporting off. */
export function configureErrorReporting(settings: ClientSettings | null): void {
const s = state();
- const on = settings && (parseDsn(settings.dsn) || settings.collect) ? settings : null;
+ watchDesktopConsent();
+ const on = !s.withdrawn && settings && (parseDsn(settings.dsn) || settings.collect) ? settings : null;
s.settings = on;
const queued = s.pending;
s.pending = [];
@@ -176,10 +207,19 @@ export function captureReport(report: ErrorReport): void {
deliver(withCrumbs);
}
+/**
+ * `CrashReportsAllowed` forced off, on any page of the desktop app. An instance's own
+ * settings decide the rest there: its admin chose what its pages report to it.
+ */
+function forbiddenByDevicePolicy(): boolean {
+ const entry = getDesktopBridge()?.policy?.get?.().CrashReportsAllowed;
+ return !!entry && entry.locked && entry.value === false;
+}
+
function deliver(report: ErrorReport): void {
const s = state();
const settings = s.settings;
- if (!settings) return;
+ if (!settings || forbiddenByDevicePolicy()) return;
const version = appVersion();
const print = fingerprint(report.name, report.frames, version, report.code);
if (!limiter().allow(print)) return;
@@ -194,7 +234,12 @@ function deliver(report: ErrorReport): void {
platform: 'javascript',
fingerprint: print,
debugIds: debugIdsByPath(),
- tags: { runtime: 'browser', ...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}) },
+ tags: {
+ runtime: 'browser',
+ ...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}),
+ // An instance's build knows nothing of the app it's in, so the bridge says.
+ ...(getDesktopBridge() ? { platform: 'desktop' } : {}),
+ },
});
send(envelopeUrl(dsn), buildEnvelope(event), {
// text/plain keeps it a simple request: no preflight, no custom header.
diff --git a/lib/error-reporting/envelope.ts b/lib/error-reporting/envelope.ts
index aaad77e1b..8cf11db17 100644
--- a/lib/error-reporting/envelope.ts
+++ b/lib/error-reporting/envelope.ts
@@ -1,3 +1,4 @@
+import { DESKTOP_VERSION_SUFFIX } from '@/lib/desktop/desktop-telemetry';
import type { Frame } from './stack';
/**
@@ -144,6 +145,11 @@ export function releaseName(): string {
return commit && commit !== 'unknown' ? `${version}+${commit}` : version;
}
+/**
+ * In the desktop build with a suffix, which also reaches the collector: its error batches
+ * have no room for a tag, and its version field allows this.
+ */
export function appVersion(): string {
- return process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
+ const version = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
+ return process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? `${version}${DESKTOP_VERSION_SUFFIX}` : version;
}
diff --git a/lib/error-reporting/scrub.ts b/lib/error-reporting/scrub.ts
index 9c24601e4..2fecb9627 100644
--- a/lib/error-reporting/scrub.ts
+++ b/lib/error-reporting/scrub.ts
@@ -14,6 +14,8 @@
* Documented at https://bulwarkmail.org/docs/deployment/monitoring#what-is-removed
*/
+import { DESKTOP_FRAME_PREFIX } from '@/lib/desktop/desktop-telemetry';
+
export const MAX_MESSAGE_LENGTH = 200;
const REDACTED = '…';
@@ -130,12 +132,18 @@ export function scrubFunctionName(input: unknown): string | undefined {
* The path of a stack frame's file, without origin, base path, query or
* fragment: `/_next/static/chunks/x.js` for the browser bundle,
* `/.next/server/...` for the server's, `node:...` for Node's own modules,
- * `node_modules/...` for dependencies; `[other]` for anything else
+ * `node_modules/...` for dependencies, in the desktop build `/desktop/...` for its main
+ * process, which the shell rewrites its own files to, so a path on disk never gets
+ * here; `[other]` for anything else
* (extensions, plugin code, evaluated code).
*/
export function scrubFramePath(input: unknown): string {
if (typeof input !== 'string' || input === '') return '[other]';
const path = input.split(/[?#]/, 1)[0].replace(/\\/g, '/');
+ // Spelled out, so a server build compiles this away and reads paths as it always did.
+ if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && path.startsWith(DESKTOP_FRAME_PREFIX)) {
+ return /^[\w./@+~-]+$/.test(path) && !path.includes('..') ? path : '[other]';
+ }
for (const marker of ['/_next/static/', '/.next/server/', '/.next/static/']) {
const at = path.indexOf(marker);
if (at >= 0) {
diff --git a/lib/error-reporting/server.ts b/lib/error-reporting/server.ts
index d94e728c3..4af8a3a11 100644
--- a/lib/error-reporting/server.ts
+++ b/lib/error-reporting/server.ts
@@ -95,6 +95,31 @@ export function captureRequestError(error: unknown, routePattern?: string): Prom
}
}
+export interface DesktopMainErrorInput {
+ name?: unknown;
+ message?: unknown;
+ stack?: unknown;
+ mechanism?: unknown;
+}
+
+/**
+ * An error in the desktop app's main process, handed over by the shell. Its frames are
+ * the shell's own files as `/desktop/...`. All of it is scrubbed here again.
+ */
+export function captureDesktopMainError(error: DesktopMainErrorInput): Promise {
+ try {
+ return handleServerReport({
+ name: scrubName(error.name),
+ message: scrubText(error.message),
+ frames: parseStack(error.stack),
+ handled: false,
+ mechanism: error.mechanism === 'unhandledrejection' ? 'desktop-main-rejection' : 'desktop-main',
+ });
+ } catch {
+ return Promise.resolve();
+ }
+}
+
/**
* logger.error: a route that caught an error and logged it. The frames are
* the logging call's, minus the logger's own `skip` frames.
diff --git a/lib/error-reporting/settings.ts b/lib/error-reporting/settings.ts
index 0e4097e32..a39196bb9 100644
--- a/lib/error-reporting/settings.ts
+++ b/lib/error-reporting/settings.ts
@@ -2,6 +2,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { parseDsn } from './envelope';
import { metricsEnabled } from '@/lib/metrics';
import { effectiveErrorsConsent } from '@/lib/telemetry/state';
+import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import type { ClientSettings } from './client';
/**
@@ -32,7 +33,8 @@ export function parseEnvironment(value: unknown): string {
export function errorReportingSettings(): ErrorReportingSettings {
const raw = configManager.get('errorReportingDsn', '');
return {
- dsn: parseDsn(raw) ? raw.trim() : '',
+ // A desktop user has no admin, so a config file can't name somewhere to send errors.
+ dsn: !IS_DESKTOP_BUILD && parseDsn(raw) ? raw.trim() : '',
sampleRate: parseSampleRate(configManager.get('errorReportingSampleRate', '1')),
environment: parseEnvironment(configManager.get('errorReportingEnvironment', 'production')),
};
@@ -45,6 +47,8 @@ export function errorReportingSettings(): ErrorReportingSettings {
*/
export async function shouldCollectErrors(): Promise {
await configManager.ensureLoaded();
+ // The desktop user's own yes, and nothing else.
+ if (IS_DESKTOP_BUILD) return (await effectiveErrorsConsent()).consent !== 'off';
if (errorReportingSettings().dsn || metricsEnabled()) return true;
return (await effectiveErrorsConsent()).consent !== 'off';
}
diff --git a/lib/error-reporting/stack.ts b/lib/error-reporting/stack.ts
index 6a6b83f00..60765fc2b 100644
--- a/lib/error-reporting/stack.ts
+++ b/lib/error-reporting/stack.ts
@@ -33,7 +33,10 @@ export function parseStack(stack: unknown): Frame[] {
for (const line of stack.split('\n')) {
const match = V8.exec(line) ?? GECKO.exec(line);
if (!match) continue;
- const [, fn, file, lineNo, column] = match;
+ const [, fn, rawFile, lineNo, column] = match;
+ // "at async file:1:2": an anonymous async frame, as main's stacks have them. Spelled
+ // out, so a server build compiles this away.
+ const file = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? rawFile.replace(/^async /, '') : rawFile;
// "at fn (native)" and eval chains carry no file worth keeping.
frames.push({
path: scrubFramePath(file),
diff --git a/lib/event-links.ts b/lib/event-links.ts
index 8c350c37d..9c4c1f867 100644
--- a/lib/event-links.ts
+++ b/lib/event-links.ts
@@ -115,6 +115,34 @@ export function findMeetingLink(event: EventPlaces): MeetingLink | null {
return null;
}
+export interface JoinLink {
+ url: string;
+ /** Set for a link to no service this app knows: say where it goes before anyone clicks. */
+ host: string | null;
+}
+
+/**
+ * The address to offer as Join: web links only, with no user name in them, since what
+ * comes before an @ only looks like the host. An app scheme can start anything on the
+ * machine (ms-msdt:, search-ms:, smb:), and this comes from whoever sent the invitation.
+ */
+export function joinLinkFrom(uri: string): JoinLink | null {
+ let parsed: URL;
+ try {
+ parsed = new URL(uri.trim());
+ } catch {
+ return null;
+ }
+ if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || parsed.username || parsed.password || !parsed.hostname) return null;
+ return { url: parsed.href, host: meetingProviderOf(parsed.href) ? null : parsed.hostname };
+}
+
+/** The event's Join, when it has one that's safe to offer. */
+export function findJoinLink(event: EventPlaces): JoinLink | null {
+ const link = findMeetingLink(event);
+ return link ? joinLinkFrom(link.uri) : null;
+}
+
/** The first location's display name, if any. */
export function primaryLocationName(event: Pick): string | undefined {
const name = Object.values(event.locations ?? {})[0]?.name?.trim();
diff --git a/lib/mail-export/__tests__/local-store-export.test.ts b/lib/mail-export/__tests__/local-store-export.test.ts
new file mode 100644
index 000000000..23c55dc4c
--- /dev/null
+++ b/lib/mail-export/__tests__/local-store-export.test.ts
@@ -0,0 +1,331 @@
+// @vitest-environment node
+/**
+ * An export from the local store, on real SQLite: the store keeps raw messages as it
+ * does in a browser, and what comes out is read back from the file.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { runExport, type ExportFormat, type ExportProgress } from '../engine';
+import { localExportClient } from '../local-store-client';
+import { memorySink, type ExportSink } from '../sinks';
+import { MboxSplitter } from '@/lib/mail-import/mbox';
+import { withoutStateHeaders } from '@/lib/mail-import/mbox-status';
+import { listZipEntries, readZipEntry } from '@/lib/mail-import/zip-reader';
+import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area';
+import { AccountEngine } from '@/lib/store/engine/account-engine';
+import { TransportError, type MethodCall } from '@/lib/store/engine/transport';
+import { mailArea } from '@/lib/store/mail';
+import { outboxArea } from '@/lib/store/outbox';
+import { FakeJmapDrafts } from '@/lib/store/__tests__/helpers/fake-drafts';
+import { FAKE_SESSION, JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap';
+import { openMemoryStore } from '@/lib/store/__tests__/helpers/node-adapter';
+
+const SCOPE: StoreScope = { ...DEFAULT_SCOPE, days: null };
+const enc = new TextEncoder();
+
+/** Bytes a rebuilt or re-encoded message wouldn't have: bare LF, CRLF, Latin-1, trailing space, an mbox trap. */
+function rawMessage(n: number): Uint8Array {
+ const head = enc.encode(`From: a${n}@example.org\r\nSubject: Secret subject ${n} \r\nDKIM-Signature: v=1; b=abc${n}\r\n\r\n`);
+ const body = enc.encode(`From the start of a line\n>From quoted once\r\nlast line ${n}\r\n`);
+ const out = new Uint8Array(head.length + 2 + body.length);
+ out.set(head);
+ out.set([0xe4, 0x0a], head.length);
+ out.set(body, head.length + 2);
+ return out;
+}
+
+class Server extends FakeJmapDrafts {
+ readonly files = new Map();
+ readonly fetched: string[] = [];
+ gone = new Set();
+
+ private read(blobId: string): Uint8Array {
+ this.fetched.push(blobId);
+ if (this.gone.has(blobId)) throw new TransportError('http', 'Gone', 404);
+ return this.files.get(blobId) ?? new Uint8Array();
+ }
+
+ override async download(_jmap?: string, blobId?: string): Promise {
+ return this.read(String(blobId));
+ }
+
+ async downloadStream(_jmap: string, blobId: string, _type: string, _name: string, sink: (part: Uint8Array) => void): Promise {
+ sink(this.read(blobId));
+ }
+}
+
+function serverWith(ids: Record