mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
Merge branch 'feat/desktop-offline' into feat/do-app-lock
# Conflicts: # components/settings/general-settings.tsx # hooks/use-calendar-alerts.ts # lib/desktop/app-lock-state.ts # lib/desktop/policy-schema.ts # lib/desktop/types.ts
This commit is contained in:
@@ -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({
|
||||
<MainWindowOnly>
|
||||
<ImpersonationReconciler />
|
||||
<PushBindingsHost />
|
||||
<DesktopOsHost />
|
||||
<FreeBusyAutoShare />
|
||||
{!IS_LITE && <PopoutHost />}
|
||||
</MainWindowOnly>
|
||||
|
||||
@@ -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 (
|
||||
<div key={wait.until} className="mb-5 p-3 rounded-xl border border-warning/30 bg-warning/5 text-sm text-foreground" role="status" data-testid="login-refused-waiting">
|
||||
{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 })}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -235,6 +235,7 @@ function wipeReasonLabel(reason: NonNullable<DeviceView['wipeReason']>): 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 (
|
||||
<SettingsSection
|
||||
id="offline"
|
||||
@@ -107,12 +107,12 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="Delete a device's mail as soon as the server refuses its login"
|
||||
description="Off: the device signs out and hides what it keeps, unsent messages included, until someone signs in with the new password, or for 30 days. That's what a password changed on another device needs. On: it's deleted right away, unsent messages included, and only that account's. A wipe you ask for below is immediate either way."
|
||||
label="Delete a device's mail as soon as it's signed out with nobody asking"
|
||||
description="That's when the server refuses its login, or the idle or session limit runs out. Off: the device signs out and hides what it keeps, unsent messages included, until the next sign-in, or for 30 days. That's what a password changed on another device needs. On: it's deleted right away, unsent messages included, and only that account's. A wipe you ask for below is immediate either way."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<ToggleSwitch checked={wipeOnRefused.value} onChange={(v) => draft.setOrgValue('devices', 'wipeOnRefusedLogin', v)} testId="org-devices-wipeOnRefusedLogin" />
|
||||
<InheritNote draft={draft} own={wipeOnRefused.own} everyone={wipeOnRefused.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'wipeOnRefusedLogin')} />
|
||||
<ToggleSwitch checked={wipeOnRefused.value} onChange={(v) => draft.setOrgValue('devices', 'wipeOnForcedSignOut', v)} testId="org-devices-wipeOnForcedSignOut" />
|
||||
<InheritNote draft={draft} own={wipeOnRefused.own} everyone={wipeOnRefused.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'wipeOnForcedSignOut')} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
@@ -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.{' '}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-2">
|
||||
How offline mode works
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={finish}
|
||||
title={t("missed_dialog_title")}
|
||||
description={t("missed_dialog_description")}
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
finish();
|
||||
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||
}}
|
||||
>
|
||||
{t("open_calendar")}
|
||||
</Button>
|
||||
<Button onClick={finish}>{t("done")}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ul className="max-h-80 overflow-y-auto divide-y divide-border" data-testid="missed-reminders">
|
||||
{alerts.map((alert) => (
|
||||
<li key={alert.key} className="flex items-center gap-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{alert.title || tAlert("alert_title")}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{format.format(new Date(alert.startMs ?? alert.fireTimeMs))}
|
||||
{alert.calendarName ? ` · ${alert.calendarName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{alert.joinUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={t("join_named", { title: alert.title || tAlert("alert_title") })}
|
||||
onClick={() => void joinMeeting(alert.joinUrl!)}
|
||||
>
|
||||
{joinLabel(alert, joinLabels)}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<MailNotifications | null>(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<IJMAPClient>(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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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<string | null>(null);
|
||||
const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId);
|
||||
|
||||
@@ -679,6 +679,7 @@ interface DraggableAttachmentChipProps {
|
||||
onPointerEnter: () => void;
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
onCopyAsFile?: () => Promise<boolean>;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -686,6 +687,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
|
||||
const source = useMemo<AttachmentDragSource>(() => ({
|
||||
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 {
|
||||
|
||||
@@ -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<HTMLDivElement>) => void;
|
||||
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||
/** The desktop app only. */
|
||||
onCopyAsFile?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Past this many tiles the bar shows one row less and a "Show N more" tile. */
|
||||
@@ -173,6 +176,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
</IconButton>
|
||||
)}
|
||||
{pluginMenu?.(attachment)}
|
||||
{!downloadsOff && drag.onCopyAsFile && <CopyAsFileButton copy={drag.onCopyAsFile} />}
|
||||
{!downloadsOff && (
|
||||
<IconButton
|
||||
label={t("download_named", { name })}
|
||||
@@ -243,6 +247,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
</IconButton>
|
||||
)}
|
||||
{pluginMenu?.(attachment)}
|
||||
{!downloadsOff && drag.onCopyAsFile && <CopyAsFileButton copy={drag.onCopyAsFile} />}
|
||||
{!downloadsOff && (
|
||||
<IconButton label={t("download_named", { name })} title={t("download")} onClick={() => onDownload(attachment)}>
|
||||
<Download className="h-4 w-4" />
|
||||
@@ -359,6 +364,25 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
* 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<boolean> }) {
|
||||
const t = useTranslations("desktopOs");
|
||||
return (
|
||||
<IconButton
|
||||
label={t("copy_as_file")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
void copy().then((ok) => {
|
||||
if (ok) toast.success(t("copied"));
|
||||
else toast.error(t("copy_failed"));
|
||||
}, () => toast.error(t("copy_failed")));
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) {
|
||||
if (url) {
|
||||
return <img src={url} alt="" loading="lazy" className="h-12 w-12 shrink-0 rounded-md bg-muted object-cover" />;
|
||||
|
||||
@@ -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(<AccountSwitcher variant="rail" />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{waiting.map((wait) => (
|
||||
<button
|
||||
key={wait.account}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="account-needs-sign-in"
|
||||
className="w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
router.push(`/login?mode=add-account&username=${encodeURIComponent(wait.username)}` as never);
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-destructive" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium truncate">{toUnicodeEmail(wait.label)}</span>
|
||||
<span className="block text-xs text-foreground">{tWipe("needs_sign_in")}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{wait.unsent
|
||||
? tWipe("switcher_waiting", { count: wait.unsent, date: waitDate(wait.until) })
|
||||
: tWipe("switcher_kept", { date: waitDate(wait.until) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mountain View: the account actions as rows closing the white card,
|
||||
|
||||
@@ -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<unknown> => {
|
||||
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, unknown>, 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<typeof DEFAULT_ORGANISATION_POLICY.devices>) {
|
||||
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<typeof vi.fn>; abort: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> } {
|
||||
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<typeof setDownloadGuard>[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(<MailTransferHost />);
|
||||
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<typeof vi.fn>;
|
||||
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(<OfflineSettings />);
|
||||
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(<OfflineSettings />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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<string, string[]>) {
|
||||
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<string, string[]>);
|
||||
// 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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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<typeof vi.fn>;
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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, unknown>, 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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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<typeof vi.fn>)).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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
await finishedJob(memorySink('a.mbox', 'application/mbox') as ReturnType<typeof fakeSink>);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
await finishedJob();
|
||||
expect(useMailTransferStore.getState().job?.progress.phase).toBe('done');
|
||||
});
|
||||
});
|
||||
@@ -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<Loaded> {
|
||||
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<number | null> {
|
||||
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<Loaded | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [excluded, setExcluded] = useState<ReadonlySet<string>>(new Set());
|
||||
const [format, setFormat] = useState<ExportFormat>("mbox");
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [refused, setRefused] = useState<string | null>(null);
|
||||
const mode = saveMode();
|
||||
const progress = job?.kind === "export" ? job.progress : null;
|
||||
const active = isJobActive(job);
|
||||
const stageButton = useRef<HTMLButtonElement>(null);
|
||||
const allFolders = useRef<HTMLInputElement | null>(null);
|
||||
const knownFolders = useRef<ReadonlySet<string>>(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 (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={() => (active ? hide() : close())}
|
||||
size="full-on-phone"
|
||||
preventOutsideClose
|
||||
title={progress.phase === "done" ? tExport("done_title") : progress.phase === "cancelled" ? tExport("cancelled_title") : progress.phase === "failed" ? tExport("failed_title") : tExport("running_title")}
|
||||
testId="local-export-dialog"
|
||||
footer={active ? (
|
||||
<>
|
||||
<Button ref={stageButton} variant="outline" onClick={hide}>{tExport("background")}</Button>
|
||||
<Button variant="destructive" onClick={() => cancelJob()} aria-describedby={`${id}-status`} data-testid="local-export-cancel">{tExport("cancel_export")}</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{progress.phase === "done" && progress.file?.download && (
|
||||
<Button variant="outline" onClick={() => progress.file?.download?.()} data-testid="mail-export-download">{tExport("download_again")}</Button>
|
||||
)}
|
||||
{notKeptCount > 0 && (
|
||||
<Button variant="outline" disabled={!reachable} onClick={() => useMailTransferStore.getState().open(request)} data-testid="local-export-retry">{t("retry")}</Button>
|
||||
)}
|
||||
<Button ref={stageButton} onClick={() => close()}>{tExport("close")}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ExportProgressBody progress={shown} t={tExport} locale={locale} statusId={`${id}-status`} />
|
||||
{progress.phase === "cancelled" && reason === "policy" && <p className="mt-4 text-sm text-destructive" role="alert" data-testid="local-export-stop-reason">{tPolicy("export_blocked")}</p>}
|
||||
{progress.phase === "cancelled" && reason === "offline-off" && <p className="mt-4 text-sm text-destructive" role="alert" data-testid="local-export-stop-reason">{t("stopped_offline_off")}</p>}
|
||||
{progress.phase === "done" && progress.exported === 0 && progress.failed === 0 && <p className="mt-4 text-sm text-foreground" data-testid="local-export-wrote-nothing">{t("nothing_kept")}</p>}
|
||||
{leftover && <p className="mt-4 text-sm text-destructive break-words" role="alert" data-testid="local-export-leftover">{t("leftover_file", { name: leftover })}</p>}
|
||||
{notKeptCount > 0 && (
|
||||
<div className="mt-4 space-y-1 text-sm text-foreground" data-testid="local-export-not-kept">
|
||||
{!!notKept?.attachments_off && <p data-testid="local-export-not-kept-attachments">{t("not_kept_attachments_off", { count: notKept.attachments_off })}</p>}
|
||||
{!!notKept?.not_kept && <p data-testid="local-export-not-kept-missing">{t("not_kept_not_kept", { count: notKept.not_kept })}</p>}
|
||||
<p className="text-xs text-muted-foreground" data-testid="local-export-retry-hint">{reachable ? t("retry_online") : t("retry_offline")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={() => close()}
|
||||
size="full-on-phone"
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
testId="local-export-dialog"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => close()}>{tExport("cancel")}</Button>
|
||||
<Button onClick={() => void onStart()} disabled={starting || !loaded || nothingToWrite || overLimit || noRoom} data-testid="local-export-start">{tExport("start")}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="mt-4 space-y-5">
|
||||
{loadFailed ? (
|
||||
<p className="text-sm text-destructive" role="alert" data-testid="local-export-load-failed">{t("load_failed")}</p>
|
||||
) : !loaded ? (
|
||||
<p className="text-sm text-muted-foreground" role="status" data-testid="local-export-loading">{t("loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-sm font-medium text-foreground">{t("folders_label")}</legend>
|
||||
<label className="flex items-center gap-2 py-1 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={excluded.size === 0}
|
||||
ref={(input) => {
|
||||
allFolders.current = input;
|
||||
if (input) input.indeterminate = excluded.size > 0 && excluded.size < rows.length;
|
||||
}}
|
||||
onChange={(event) => setExcluded(event.target.checked ? new Set() : new Set(rows.map((row) => row.folder.mailboxId)))}
|
||||
data-testid="local-export-all"
|
||||
/>
|
||||
{t("all_folders")}
|
||||
</label>
|
||||
<ul className="max-h-56 overflow-y-auto rounded-md border border-border">
|
||||
{rows.map((row) => (
|
||||
<li key={row.folder.mailboxId} className="border-b border-border last:border-b-0">
|
||||
<label className="flex items-start gap-2 px-3 py-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 shrink-0"
|
||||
checked={!excluded.has(row.folder.mailboxId)}
|
||||
onChange={(event) => toggleFolder(row.folder.mailboxId, event.target.checked)}
|
||||
data-testid="local-export-folder"
|
||||
data-folder={row.folder.path.join("/")}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">{row.folder.path.join(" / ")}</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{t("folder_kept", { kept: row.kept, total: row.total })}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-sm font-medium text-foreground">{tExport("format_label")}</legend>
|
||||
<div className="space-y-2">
|
||||
{formats.map((f) => (
|
||||
<label key={f.value} className="flex items-start gap-2 rounded-md border border-border p-3 text-sm text-foreground has-[:checked]:border-primary">
|
||||
<input
|
||||
type="radio"
|
||||
name={`${id}-format`}
|
||||
value={f.value}
|
||||
checked={format === f.value}
|
||||
onChange={() => setFormat(f.value)}
|
||||
className="mt-0.5"
|
||||
aria-describedby={`${id}-${f.value}-hint`}
|
||||
data-testid={`local-export-format-${f.value}`}
|
||||
/>
|
||||
<span>
|
||||
{f.label}
|
||||
<span id={`${id}-${f.value}-hint`} className="block text-xs text-muted-foreground">{f.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-1 text-sm text-foreground" role="status" aria-live="polite" data-testid="local-export-summary">
|
||||
{total === 0 ? (
|
||||
<p data-testid="local-export-nothing">{t("nothing_kept")}</p>
|
||||
) : (
|
||||
<p data-testid="local-export-kept">{t("kept", { kept, total })}</p>
|
||||
)}
|
||||
{missing > 0 && (
|
||||
<p data-testid="local-export-missing" className={reachable ? undefined : "font-medium"}>
|
||||
{reachable ? t("missing_online", { count: missing }) : t("missing_offline", { count: missing })}
|
||||
</p>
|
||||
)}
|
||||
{missing > 0 && !reachable && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="local-export-reason">{attachmentsOff ? t("reason_attachments_off") : t("reason_not_kept")}</p>
|
||||
)}
|
||||
{overLimit && <p className="font-medium text-destructive" data-testid="local-export-over-limit">{t("over_limit", { limit: formatFileSize(limit), size: formatFileSize(needed) })}</p>}
|
||||
{noRoom && !overLimit && <p className="font-medium text-destructive" data-testid="local-export-no-room">{t("no_room", { size: formatFileSize(needed), free: formatFileSize(loaded?.free ?? 0) })}</p>}
|
||||
{refused && <p className="font-medium text-destructive" data-testid="local-export-refused">{refused}</p>}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{tExport(`save_${mode}`)}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof useTranslations>;
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<void> {
|
||||
// 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" && <MailImportDialog request={request} />}
|
||||
{request?.kind === "export" && <MailExportDialog request={request} />}
|
||||
{request?.kind === "local-export" && <LocalExportDialog request={request} />}
|
||||
{!visible && isJobActive(job) && <TransferChip />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<string | null> {
|
||||
const session = await getStore().area<StoreSession | null>(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<void>((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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(<DeviceWipeHost />);
|
||||
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(<DeviceWipeHost />);
|
||||
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(<DeviceWipeHost />);
|
||||
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(<DeviceWipeHost />);
|
||||
expect(warning).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
</SettingItem>
|
||||
)}
|
||||
<KeptFiles account={account} />
|
||||
<SettingItem label={tExport('settings_label')} description={tExport('settings_description')} locked={!localExportAllowed(account)}>
|
||||
<Button variant="outline" size="sm" disabled={!localExportAllowed(account)} onClick={() => openLocalExport(account)} data-testid={testId('local-export-open')}>{tExport('settings_button')}</Button>
|
||||
</SettingItem>
|
||||
<SettingItem label={tStorage('clear_label')} description={cantClear ? tStorage('clear_needs_connection') : tStorage('clear_description')}>
|
||||
<Button variant="outline" size="sm" disabled={cantClear} onClick={askClear} data-testid={testId('offline-clear')}>{tStorage('clear_button')}</Button>
|
||||
</SettingItem>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string>());
|
||||
const { dialogProps, confirm } = useConfirmDialog();
|
||||
const [offer, setOffer] = useState<ShareOffer | null>(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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||
<MissedRemindersDialog />
|
||||
<LiveRegions />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof import('next-intl')>()),
|
||||
useTranslations: () => (key: string) => key,
|
||||
useFormatter: () => ({ dateTime: (date: Date) => date.toISOString() }),
|
||||
}));
|
||||
vi.mock('../settings-section', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
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<DesktopCrashSendResult> => ({ 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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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(<DesktopTelemetrySettings />);
|
||||
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<string, string>;
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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<DesktopOsStatus | null>(null);
|
||||
const [defaultApp, setDefaultApp] = useState<DesktopOsDefaultAppStatus | null>(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<Settings>) => {
|
||||
void os.setSettings(patch).then(setStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection id="desktop-app" title={t("section_title")} description={t("section_description")}>
|
||||
<SettingItem label={t("close_to_tray_label")} description={t("close_to_tray_description")}>
|
||||
<ToggleSwitch checked={settings.closeToTray} onChange={(closeToTray) => change({ closeToTray })} />
|
||||
</SettingItem>
|
||||
<SettingItem label={t("start_at_login_label")} description={t("start_at_login_description")}>
|
||||
<ToggleSwitch checked={settings.startAtLogin} onChange={(startAtLogin) => change({ startAtLogin })} />
|
||||
</SettingItem>
|
||||
{settings.startAtLogin && (
|
||||
<SettingItem nested label={t("start_minimized_label")} description={t("start_minimized_description")}>
|
||||
<ToggleSwitch checked={settings.startMinimized} onChange={(startMinimized) => change({ startMinimized })} />
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem
|
||||
label={bridge?.platform === "darwin" ? t("attention_mac_label") : t("attention_label")}
|
||||
description={t("attention_description")}
|
||||
>
|
||||
<ToggleSwitch checked={settings.attentionOnNewMail} onChange={(attentionOnNewMail) => change({ attentionOnNewMail })} />
|
||||
</SettingItem>
|
||||
<SettingItem label={t("quiet_hours_label")} description={t("quiet_hours_description")}>
|
||||
<ToggleSwitch checked={settings.quietHoursEnabled} onChange={(quietHoursEnabled) => change({ quietHoursEnabled })} />
|
||||
</SettingItem>
|
||||
{settings.quietHoursEnabled && (
|
||||
<SettingItem nested label={t("quiet_hours_from")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t("quiet_hours_from")}
|
||||
value={settings.quietHoursStart}
|
||||
onChange={(event) => change({ quietHoursStart: event.target.value })}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("quiet_hours_to")}</span>
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t("quiet_hours_to")}
|
||||
value={settings.quietHoursEnd}
|
||||
onChange={(event) => change({ quietHoursEnd: event.target.value })}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem
|
||||
label={t("show_shortcut_label")}
|
||||
description={settings.showShortcut && !status.shortcutActive
|
||||
? t("show_shortcut_taken", { keys: shortcutLabel(bridge?.platform) })
|
||||
: t("show_shortcut_description", { keys: shortcutLabel(bridge?.platform) })}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={!!settings.showShortcut}
|
||||
onChange={(on) => change({ showShortcut: on ? SHOW_SHORTCUT : null })}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label={t("default_app_label")}
|
||||
description={defaultApp?.mailto ? t("default_app_is_default") : t("default_app_description")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void os.openDefaultAppSettings().then(setDefaultApp)}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
{t("default_app_button")}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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<Exclude<DesktopCrashSendResult, { ok: true }>["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<DesktopPrivacyState | null>(null);
|
||||
const [crashes, setCrashes] = useState<DesktopCrashFile[]>([]);
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlInvalid, setUrlInvalid] = useState(false);
|
||||
const [sending, setSending] = useState<string | null>(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 (
|
||||
<SettingsSection id="desktop-privacy" title={t("section_title")} description={t("section_description")}>
|
||||
<SettingItem
|
||||
label={t("telemetry_label")}
|
||||
description={telemetry.forcedOff ? t("forced_off") : t("telemetry_description")}
|
||||
locked={telemetry.forcedOff}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={telemetry.on}
|
||||
disabled={telemetry.forcedOff}
|
||||
testId="desktop-telemetry-toggle"
|
||||
onChange={(on) => void privacy.setTelemetry(on).then(setState)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label={t("crash_label")}
|
||||
description={crashReports.forcedOff ? t("forced_off") : t("crash_description")}
|
||||
locked={crashReports.forcedOff}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={crashReports.on}
|
||||
disabled={crashReports.forcedOff}
|
||||
testId="desktop-crash-toggle"
|
||||
onChange={(on) => void privacy.setCrashReports(on).then((next) => {
|
||||
setState(next);
|
||||
refreshCrashes();
|
||||
})}
|
||||
/>
|
||||
</SettingItem>
|
||||
{!crashReports.forcedOff && (
|
||||
<p className="py-2 text-xs text-muted-foreground">
|
||||
{t("crash_memory")}
|
||||
{bridge?.mode === "instance" && ` ${t("instance_note")}`}
|
||||
</p>
|
||||
)}
|
||||
{crashReports.on && (
|
||||
<SettingItem nested label={t("url_label")} description={urlInvalid ? t("url_invalid") : t("url_description")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
value={url}
|
||||
placeholder="https://crash.example.org/upload"
|
||||
data-testid="desktop-crash-url"
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
setUrlInvalid(false);
|
||||
}}
|
||||
className="w-64"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => void saveUrl()}>{t("url_save")}</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
{showCrashFiles && (
|
||||
<SettingItem nested label={t("files_label")} description={t("files_description")}>
|
||||
{crashes.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void privacy.deleteCrashes().then(refreshCrashes)}
|
||||
>
|
||||
{t("delete_all")}
|
||||
</Button>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
{showCrashFiles && crashes.length === 0 && (
|
||||
<p className="ms-4 ps-3 py-2 text-xs text-muted-foreground">{t("files_empty")}</p>
|
||||
)}
|
||||
{crashes.map((crash) => (
|
||||
<SettingItem
|
||||
key={crash.id}
|
||||
nested
|
||||
label={format.dateTime(new Date(crash.createdAt), { dateStyle: "medium", timeStyle: "short" })}
|
||||
description={t("file_size", { size: Math.max(1, Math.round(crash.bytes / 1024)) })}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={sending !== null}
|
||||
data-testid="desktop-crash-send"
|
||||
onClick={() => void send(crash.id)}
|
||||
>
|
||||
{t("send")}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<AppLockSettings />
|
||||
|
||||
<DesktopOsSettings />
|
||||
|
||||
{!IS_LITE && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
<DesktopKeystoreSettings />
|
||||
<DesktopTelemetrySettings />
|
||||
|
||||
<DesktopWindowSettings />
|
||||
<SettingsSection id="tour" title={tTour('settings_section')}>
|
||||
|
||||
@@ -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(
|
||||
<ToastItem
|
||||
onClose={vi.fn()}
|
||||
toast={{
|
||||
id: 't',
|
||||
type: 'info',
|
||||
title: 'Standup',
|
||||
duration: 0,
|
||||
onDismiss,
|
||||
tertiaryAction: action('five'),
|
||||
secondaryAction: action('ten'),
|
||||
action: action('fifteen'),
|
||||
extraActions: [action('join'), action('open')],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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) => <span key={action.label} className="contents">{actionButton(action)}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<HTMLDivElement>));
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<typeof import('@/lib/device-policy')>(),
|
||||
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<string, unknown> = {}) {
|
||||
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 <ToastContainer toasts={toasts} onClose={removeToast} />;
|
||||
}
|
||||
|
||||
async function advance(ms: number) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function syncedWith(...events: Record<string, unknown>[]) {
|
||||
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<string, unknown>[]) {
|
||||
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(<Toasts />);
|
||||
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(<Toasts />);
|
||||
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(<><Toasts /><MissedRemindersDialog /></>);
|
||||
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(<Toasts />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import('@/lib/popout/window-role')>(),
|
||||
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<string, unknown> = {}) {
|
||||
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 <ToastContainer toasts={toasts} onClose={removeToast} />;
|
||||
}
|
||||
|
||||
async function advance(ms: number) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function syncedWith(...events: Record<string, unknown>[]) {
|
||||
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(<Toasts />);
|
||||
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(<Toasts />);
|
||||
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(<><Toasts /><MissedRemindersDialog /></>);
|
||||
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(<Toasts />);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string | null>;
|
||||
/** The bytes themselves, for the desktop app's native drag. */
|
||||
getBlob?: () => Promise<Blob | null>;
|
||||
}
|
||||
|
||||
export interface UseAttachmentDragResult {
|
||||
@@ -40,6 +43,8 @@ export interface UseAttachmentDragResult {
|
||||
onPointerEnter: () => void;
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||
/** The desktop app: put the file on the clipboard, for pasting into Explorer or Finder. */
|
||||
onCopyAsFile?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const NOOP_HANDLERS: UseAttachmentDragResult = {
|
||||
@@ -56,6 +61,8 @@ export function useAttachmentDrag(
|
||||
const urlRef = useRef<string | null>(null);
|
||||
const ownedRef = useRef<boolean>(false);
|
||||
const inFlightRef = useRef<Promise<string | null> | null>(null);
|
||||
const blobRef = useRef<Blob | null>(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<HTMLDivElement>) => {
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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 {
|
||||
|
||||
+25
-4
@@ -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<string> {
|
||||
@@ -126,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise<string> {
|
||||
}),
|
||||
);
|
||||
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: <mime>:<filename>:<url>. 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}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof setInterval> | 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.
|
||||
*/
|
||||
|
||||
@@ -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<ReturnType<typeof createReminderRunner> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn || isPopoutWindow()) return;
|
||||
const toastsByKey = new Map<string, string>();
|
||||
const loans = new Set<ReturnType<typeof setInterval>>();
|
||||
|
||||
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<MissedBatch | null>(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]);
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
@@ -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<JmapClient> | 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 <seed@example.org>\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<void> };
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<void> {
|
||||
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<string> }).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<void> {
|
||||
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<Buffer | null> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -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<ReturnType<typeof jmapAs>>;
|
||||
let client: J;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
const created: string[] = [];
|
||||
|
||||
async function call(calls: [string, Record<string, unknown>, 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, unknown>, 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<string, unknown> = {}): Promise<string> {
|
||||
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<string, { id: string }> | 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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -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<CollectorValidator | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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<typeof import('@/lib/telemetry/endpoint-guard')>();
|
||||
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<symbol, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<symbol, unknown>;
|
||||
|
||||
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<void>((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<string, Record<string, { frames: { path: string }[] }>>;
|
||||
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();
|
||||
});
|
||||
@@ -7,15 +7,15 @@ import { sanitizeOrganisationPartial } from '../organisation-policy';
|
||||
|
||||
const scope = (id: string, devices: Record<string, unknown>) => ({ 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Organisat
|
||||
noOfflineAttachments: bool(dev.noOfflineAttachments),
|
||||
offlineMaxAttachmentMb: count(dev.offlineMaxAttachmentMb, MAX_OFFLINE_MB),
|
||||
offlineMaxTotalMb: count(dev.offlineMaxTotalMb, MAX_OFFLINE_MB),
|
||||
wipeOnRefusedLogin: bool(dev.wipeOnRefusedLogin),
|
||||
wipeOnForcedSignOut: bool(dev.wipeOnForcedSignOut),
|
||||
});
|
||||
|
||||
const cal = section('calendars');
|
||||
|
||||
@@ -39,8 +39,8 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
|
||||
host: 'telemetry.bulwarkmail.org',
|
||||
files: ['lib/telemetry/types.ts'],
|
||||
side: 'server',
|
||||
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.',
|
||||
off: 'Off by default. BULWARK_TELEMETRY=off, or System > 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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown> }).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();
|
||||
|
||||
@@ -288,8 +288,10 @@ async function settleCheck(): Promise<void> {
|
||||
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. */
|
||||
|
||||
@@ -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<unknown> = Promise.resolve();
|
||||
return {
|
||||
request: vi.fn(<T,>(_name: string, callback: () => T | Promise<T>) => {
|
||||
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<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const asked: string[] = [];
|
||||
let queue: Promise<unknown> = held;
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: {
|
||||
request: <T,>(name: string, callback: () => T | Promise<T>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<Pick<Tab, 'hidden' | 'offline'>> = {}): 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<MissedBatch | null>(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<string, unknown>[]): Promise<void> {
|
||||
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<Tab> {
|
||||
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<MissedBatch>(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);
|
||||
});
|
||||
});
|
||||
@@ -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<AcknowledgedMessage>) => {
|
||||
if (event.data?.kind === 'acknowledged' && Array.isArray(event.data.keys)) listener(event.data.keys);
|
||||
};
|
||||
return () => channel.close();
|
||||
}
|
||||
@@ -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<string, number> {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as unknown;
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, number> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeClaim(claims: Record<string, number>, 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;
|
||||
@@ -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<StoreBackend, 'area' | 'sync'>;
|
||||
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<void>;
|
||||
/** 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<typeof setTimeout> | null = null;
|
||||
let timerAt = 0;
|
||||
let clockWatch: ReturnType<typeof setInterval> | null = null;
|
||||
let lastWatch = 0;
|
||||
let running = false;
|
||||
let again = false;
|
||||
let stopped = true;
|
||||
let summaryAt = 0;
|
||||
const presenting = new Set<string>();
|
||||
const known = new Set<string>();
|
||||
const lastPoll = new Map<string, number>();
|
||||
|
||||
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<number | null> => {
|
||||
let result: TickResult;
|
||||
try {
|
||||
result = await env.store().area<TickResult>(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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, true>;
|
||||
setDriven: (account: string, driven: boolean) => void;
|
||||
}
|
||||
|
||||
export const useReminderSource = create<ReminderSourceState>()((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<MissedRemindersState>()((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;
|
||||
}
|
||||
@@ -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<ServiceWorkerRegistration | undefined> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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<string, Email>, 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<BulwarkDesktopBridge> | 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<string, Email> = {};
|
||||
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<Email> = {}) => ({ 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import('@/lib/store/client/api')>()),
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
function setBridge(bridge: Partial<BulwarkDesktopBridge['os']> | 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<string, unknown>).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<BulwarkDesktopBridge['os']> = {}) {
|
||||
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<CalendarEvent> = {}) => ({ 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<DesktopOsStatus['settings']>) => ({ ...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(<DesktopOsSettings />);
|
||||
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(<DesktopOsSettings />);
|
||||
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(<DesktopOsSettings />);
|
||||
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(<DesktopOsSettings />);
|
||||
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(<DesktopOsSettings />);
|
||||
expect(await screen.findByText('attention_mac_label')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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<string> {
|
||||
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<TickResult>(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<string, unknown> = {}): Promise<ReminderAlert> {
|
||||
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<TickResult>(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<TickResult>(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
|
||||
vi.setSystemTime(FIRE + 10 * MIN);
|
||||
expect((await store.area<TickResult>(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<TickResult>(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<TickResult>(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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.<mode>.<channel>`. */
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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<string, Blob>();
|
||||
|
||||
/** 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<boolean> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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>): void {
|
||||
if (opener) {
|
||||
opener(link, options);
|
||||
return;
|
||||
}
|
||||
waiting = { link, options };
|
||||
void leaveFor('/');
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetMailLinks(): void {
|
||||
opener = null;
|
||||
waiting = null;
|
||||
}
|
||||
@@ -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<Sender> {
|
||||
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<DesktopOsNotification> {
|
||||
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<typeof setTimeout> | 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;
|
||||
}
|
||||
@@ -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<IJMAPClient, Map<string, string>>();
|
||||
const seen: string[] = [];
|
||||
let queue: Promise<void> = 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<void>) => {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
/** 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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<NotificationActionStatus> {
|
||||
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<void> {
|
||||
// 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<DesktopOsCommand, { type: 'files' }>, 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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<DesktopOsSummary> {
|
||||
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<typeof setTimeout> | 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -823,8 +823,8 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
|
||||
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' },
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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<Pick<ReminderToastContext, 'slot' | 'login'>> {
|
||||
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<ReminderAlert, 'joinHost'>, labels: Pick<ReminderLabels, 'join' | 'joinHost'>): 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<DesktopOsNotification> {
|
||||
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<ReminderLabels, 'untitled' | 'missedMore'>): 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<DesktopOsNotification> {
|
||||
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<DesktopOsNotifyResult | undefined> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
announceAcknowledged([key]);
|
||||
await getStore().area<boolean>(account, 'reminders.snooze', { key, minutes });
|
||||
pokeReminders();
|
||||
}
|
||||
|
||||
export interface ReminderCommandEnv {
|
||||
leaveFor: (path: '/calendar') => void | Promise<void>;
|
||||
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<boolean> {
|
||||
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<ReminderAlert[]>(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<ReminderAlert | null>(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;
|
||||
}
|
||||
@@ -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<symbol, unknown>;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<symbol, DesktopServerPrivacy | undefined>)[PRIVACY_KEY] ?? null;
|
||||
}
|
||||
@@ -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<Handler>();
|
||||
const sleepHandlers = new Set<Handler>();
|
||||
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<Handler>): 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<DesktopOsStatus>;
|
||||
setSettings: (patch: Partial<DesktopOsSettings>) => Promise<DesktopOsStatus>;
|
||||
/** 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<DesktopOsNotifyResult>;
|
||||
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<boolean>;
|
||||
/** The same, onto the clipboard as files. */
|
||||
copyFiles: (files: DesktopOsFile[]) => Promise<boolean>;
|
||||
defaultAppStatus: () => Promise<DesktopOsDefaultAppStatus>;
|
||||
/** Offers the app to the OS and opens where the user chooses. */
|
||||
openDefaultAppSettings: () => Promise<DesktopOsDefaultAppStatus>;
|
||||
}
|
||||
|
||||
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<DesktopPrivacyState>;
|
||||
setTelemetry: (on: boolean) => Promise<DesktopPrivacyState>;
|
||||
setCrashReports: (on: boolean) => Promise<DesktopPrivacyState>;
|
||||
/** https, or http on this computer. Empty clears it. */
|
||||
setCrashUploadUrl: (url: string) => Promise<{ ok: boolean; state: DesktopPrivacyState }>;
|
||||
listCrashes: () => Promise<DesktopCrashFile[]>;
|
||||
sendCrash: (id: string) => Promise<DesktopCrashSendResult>;
|
||||
deleteCrashes: () => Promise<void>;
|
||||
/** 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 {
|
||||
|
||||
@@ -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<HTMLElement> & { 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<Element> | 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);
|
||||
};
|
||||
}
|
||||
@@ -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}$/;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, RefusedLogin> {
|
||||
@@ -134,14 +152,24 @@ function writeRefused(refused: Record<string, RefusedLogin>): 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 ----
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAggregateForTests();
|
||||
// The server's limiter sends one per fingerprint per hour.
|
||||
delete (globalThis as unknown as Record<symbol, unknown>)[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');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
const eventOf = (init: RequestInit) => JSON.parse(String(init.body).trim().split('\n')[2]) as { tags: Record<string, string> };
|
||||
|
||||
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})?$/);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user