From 947743cc9e900334d805ffe62ad30a6beb535794 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:13:06 +0100 Subject: [PATCH 01/45] feat: connect the desktop app's tray, badge, notifications, files and settings to the webmail --- app/(main)/[locale]/layout.tsx | 2 + components/desktop/desktop-os-host.tsx | 101 +++++ components/email/email-composer.tsx | 4 + components/email/email-viewer.tsx | 1 + components/email/reader-attachments.tsx | 26 +- components/mail/mail-app.tsx | 12 + .../protocol-launch-handler-provider.tsx | 16 + components/settings/desktop-os-settings.tsx | 120 +++++ components/settings/general-settings.tsx | 3 + .../__tests__/use-email-drag-routing.test.tsx | 29 ++ hooks/__tests__/use-new-mail-alerts.test.tsx | 8 + hooks/use-app-badge.ts | 9 + hooks/use-attachment-drag.ts | 16 + hooks/use-email-drag.ts | 13 + hooks/use-new-mail-alerts.ts | 4 +- hooks/use-policy-refresh.ts | 7 +- lib/desktop/__tests__/os-commands.test.ts | 245 ++++++++++ lib/desktop/__tests__/os-mail.test.ts | 338 ++++++++++++++ lib/desktop/__tests__/os-window.test.tsx | 425 ++++++++++++++++++ lib/desktop/app-lock-state.ts | 19 + lib/desktop/file-drag.ts | 55 +++ lib/desktop/mail-links.ts | 44 ++ lib/desktop/mail-notifications.ts | 184 ++++++++ lib/desktop/new-mail.ts | 149 ++++++ lib/desktop/os-commands.ts | 181 ++++++++ lib/desktop/os-summary.ts | 130 ++++++ lib/desktop/power.ts | 70 +++ lib/desktop/types.ts | 117 +++++ lib/desktop/window-files.ts | 66 +++ lib/pwa-notify/__tests__/page-actions.test.ts | 38 +- lib/pwa-notify/page-actions.ts | 22 +- locales/ar/common.json | 30 ++ locales/ca/common.json | 30 ++ locales/cs/common.json | 30 ++ locales/da/common.json | 30 ++ locales/de/common.json | 30 ++ locales/en/common.json | 30 ++ locales/es/common.json | 30 ++ locales/et/common.json | 30 ++ locales/fa/common.json | 30 ++ locales/fr/common.json | 30 ++ locales/he/common.json | 30 ++ locales/hu/common.json | 30 ++ locales/it/common.json | 30 ++ locales/ja/common.json | 30 ++ locales/ko/common.json | 30 ++ locales/lv/common.json | 30 ++ locales/mn/common.json | 30 ++ locales/nb/common.json | 30 ++ locales/nl/common.json | 30 ++ locales/pl/common.json | 30 ++ locales/pt/common.json | 30 ++ locales/ro/common.json | 30 ++ locales/ru/common.json | 30 ++ locales/sk/common.json | 30 ++ locales/th/common.json | 30 ++ locales/tr/common.json | 30 ++ locales/uk/common.json | 30 ++ locales/vi/common.json | 30 ++ locales/zh-TW/common.json | 30 ++ locales/zh/common.json | 30 ++ 61 files changed, 3348 insertions(+), 6 deletions(-) create mode 100644 components/desktop/desktop-os-host.tsx create mode 100644 components/settings/desktop-os-settings.tsx create mode 100644 lib/desktop/__tests__/os-commands.test.ts create mode 100644 lib/desktop/__tests__/os-mail.test.ts create mode 100644 lib/desktop/__tests__/os-window.test.tsx create mode 100644 lib/desktop/app-lock-state.ts create mode 100644 lib/desktop/file-drag.ts create mode 100644 lib/desktop/mail-links.ts create mode 100644 lib/desktop/mail-notifications.ts create mode 100644 lib/desktop/new-mail.ts create mode 100644 lib/desktop/os-commands.ts create mode 100644 lib/desktop/os-summary.ts create mode 100644 lib/desktop/power.ts create mode 100644 lib/desktop/window-files.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 992bef2ec..fca72c365 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -28,6 +28,7 @@ import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { AppUpdatePrompt } from "@/components/app-update-prompt"; import { OfflineHost } from "@/components/offline/offline-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"; @@ -85,6 +86,7 @@ export default async function LocaleLayout({ + {!IS_LITE && } diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx new file mode 100644 index 000000000..c8d2755d8 --- /dev/null +++ b/components/desktop/desktop-os-host.tsx @@ -0,0 +1,101 @@ +"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 { 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 { calendarHooks } from "@/lib/plugin-hooks"; +import type { CalendarAlertView } from "@/lib/plugin-types"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useSettingsStore } from "@/stores/settings-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 tCalendar = useTranslations("calendar.notifications"); + const locale = useLocale(); + const archiveEnabled = useArchiveActionEnabled(); + const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n")); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const mailRef = useRef(null); + const summaryRef = useRef<{ refresh: () => void } | null>(null); + const localeRef = useRef(locale); + localeRef.current = locale; + + const labels: MailNotificationLabels = { + newMail: t("new_mail"), + newMailCount: (count) => tNotify("new_mail_count", { count }), + noSubject: t("no_subject"), + archive: archiveEnabled ? tPush("action_archive") : null, + markRead: tPush("action_mark_read"), + delete: t("action_delete"), + reply: tPush("action_reply"), + replyPlaceholder: tPush("reply_placeholder"), + }; + const labelsRef = useRef(labels); + labelsRef.current = labels; + + useEffect(() => { + if (!getDesktopBridge()?.os) return; + const mail = startMailNotifications(labelsRef.current); + mailRef.current = mail; + const summary = startOsSummary(() => localeRef.current); + summaryRef.current = summary; + const stopPower = startPowerWatch(); + const stopFiles = startWindowFiles(); + // Reminders' own native notification (snooze, join) comes with the reminders work. + // Until then a reminder must not fire into a hidden window unseen. + const reminders = calendarHooks.onCalendarAlert.register("desktop-os", (view: CalendarAlertView) => { + const { calendarNotificationsEnabled } = useSettingsStore.getState(); + if (!calendarNotificationsEnabled) return; + const event = "eventId" in view; + void getDesktopBridge()?.os?.notify({ + id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`, + title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"), + body: "", + context: "{}", + }); + }); + return () => { + mail.stop(); + summary.stop(); + stopPower(); + stopFiles(); + reminders.dispose(); + mailRef.current = null; + summaryRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + mailRef.current?.setLabels(labels); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [locale, archiveEnabled]); + + // Every login needs a state to work from before its first push. + useEffect(() => { + const mail = mailRef.current; + if (!mail || !isAuthenticated) return; + const auth = useAuthStore.getState(); + const clients = new Set(useAccountStore.getState().accounts.flatMap((account) => auth.getClientForAccount(account.id) ?? [])); + if (auth.client) clients.add(auth.client); + for (const client of clients) mail.seed(client); + summaryRef.current?.refresh(); + }, [isAuthenticated, clientKey]); + + return null; +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d4ff3a9a8..31341f1db 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -168,6 +168,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; 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 @@ -2642,6 +2643,9 @@ export function EmailComposer({ resetDragTimeout(); }, [resetDragTimeout]); + // The desktop app: files dropped or pasted anywhere on the window come here. + useEffect(() => setComposerFileTarget((files) => void addFiles(files)), [addFiles]); + const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e75a260ca..09c925b5e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -677,6 +677,7 @@ interface DraggableAttachmentChipProps { onPointerEnter: () => void; onDragStart: (e: React.DragEvent) => void; onDragEnd: (e: React.DragEvent) => void; + onCopyAsFile?: () => Promise; }) => React.ReactNode; } diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index 0e3aa1fbd..c92d2feca 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -2,12 +2,13 @@ import { 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 { 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 { @@ -26,6 +27,8 @@ export interface AttachmentDragProps { onPointerEnter: () => void; onDragStart: (e: DragEvent) => void; onDragEnd: (e: DragEvent) => void; + /** The desktop app only. */ + onCopyAsFile?: () => Promise; } /** Past this many tiles the bar shows one row less and a "Show N more" tile. */ @@ -159,6 +162,7 @@ export function ReaderAttachments({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( ({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( onDownload(attachment)}> @@ -332,6 +337,25 @@ export function ReaderAttachments({ * The image itself when there is a thumbnail, else the file type's icon over * its extension, in the type's colour. */ +/** The desktop app's "Copy as file", for pasting the attachment into Explorer or Finder. */ +function CopyAsFileButton({ copy }: { copy: () => Promise }) { + const t = useTranslations("desktopOs"); + return ( + { + void copy().then((ok) => { + if (ok) toast.success(t("copied")); + else toast.error(t("copy_failed")); + }, () => toast.error(t("copy_failed"))); + }} + > + + + ); +} + function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) { if (url) { return ; diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index d475f4f2c..632c09b4f 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -143,6 +143,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"; @@ -2922,6 +2923,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }); }, []); + // The desktop app's tray, jump list and toasts, like any other permalink. A live + // composer's text goes through its own "Save or discard?" first. + useEffect(() => { + return setMailLinkOpener((link, options) => { + const open = () => void applyMailDeepLinkRef.current(link, options); + const requestClose = composerRequestCloseRef.current; + if (link.kind === 'message' && link.reply && composerOpenRef.current && requestClose) requestClose(open); + else open(); + }); + }, []); + // A plugin opening a message (api.ui.openEmail), as a click on a search // result entry naming one does: handleEmailSelect, set once it is defined. // Not inside the Pro shell, which keeps this tab mounted while hidden and diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index d3d8f4942..0078cd8bf 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -7,6 +7,7 @@ import { usePathname, useRouter } from "@/i18n/navigation"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfig } from "@/hooks/use-config"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { startOsCommands } from "@/lib/desktop/os-commands"; import { flushComposers } from "@/lib/compose-flush-registry"; import { isPopoutPath } from "@/lib/popout/window-role"; import { isStandaloneDisplayMode } from "@/lib/standalone-display"; @@ -201,6 +202,21 @@ 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")), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return; diff --git a/components/settings/desktop-os-settings.tsx b/components/settings/desktop-os-settings.tsx new file mode 100644 index 000000000..1703487e6 --- /dev/null +++ b/components/settings/desktop-os-settings.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +/** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */ +const SHOW_SHORTCUT = "CommandOrControl+Shift+B"; + +function shortcutLabel(platform: string | undefined): string { + return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B"; +} + +/** + * The desktop app's own settings, on the General page: only where there's an app to + * set. A browser never sees it, and it renders nothing until the bridge has answered. + * Quick compose adds its shortcut here later. + */ +export function DesktopOsSettings() { + const t = useTranslations("desktopOs"); + const [status, setStatus] = useState(null); + const [defaultApp, setDefaultApp] = useState(null); + const bridge = getDesktopBridge(); + const os = bridge?.os; + + useEffect(() => { + if (!os) return; + let stale = false; + void os.getStatus().then((next) => { + if (!stale) setStatus(next); + }); + void os.defaultAppStatus().then((next) => { + if (!stale) setDefaultApp(next); + }); + const stop = os.onStatusChange(setStatus); + return () => { + stale = true; + stop(); + }; + }, [os]); + + if (!os || !status) return null; + const { settings } = status; + const change = (patch: Partial) => { + void os.setSettings(patch).then(setStatus); + }; + + return ( + + + change({ closeToTray })} /> + + + change({ startAtLogin })} /> + + {settings.startAtLogin && ( + + change({ startMinimized })} /> + + )} + + change({ attentionOnNewMail })} /> + + + change({ quietHoursEnabled })} /> + + {settings.quietHoursEnabled && ( + +
+ change({ quietHoursStart: event.target.value })} + className="rounded-md border border-border bg-background px-2 py-1 text-sm" + /> + {t("quiet_hours_to")} + change({ quietHoursEnd: event.target.value })} + className="rounded-md border border-border bg-background px-2 py-1 text-sm" + /> +
+
+ )} + + change({ showShortcut: on ? SHOW_SHORTCUT : null })} + /> + + + + +
+ ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 41d2ba072..3493681ca 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -5,6 +5,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { LanguageSettings } from './language-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'; @@ -24,6 +25,8 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
+ + {!IS_LITE && } diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx index e53285031..242f40e5f 100644 --- a/hooks/__tests__/use-email-drag-routing.test.tsx +++ b/hooks/__tests__/use-email-drag-routing.test.tsx @@ -731,4 +731,33 @@ 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 { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + await warm(result); + const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never; + vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(await readBlob(objects.get(url)!)))); + try { + startDrag.mockClear(); + // Plain: still a move to a folder, still DownloadURL for Explorer. + expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(true); + expect(startDrag).toHaveBeenCalled(); + expect(nativeDrag).not.toHaveBeenCalled(); + + startDrag.mockClear(); + const preventDefault = vi.fn(); + const dataTransfer = { setData: vi.fn(), setDragImage: vi.fn(), effectAllowed: '' }; + act(() => result.current.dragHandlers.onDragStart({ altKey: true, preventDefault, dataTransfer } as unknown as DragEvent)); + expect(preventDefault).toHaveBeenCalled(); + expect(startDrag).not.toHaveBeenCalled(); + await waitFor(() => expect(nativeDrag).toHaveBeenCalled(), { container: document.createElement('div') }); + const [files] = nativeDrag.mock.calls[0]; + expect(files[0].name).toBe('Hello world.eml'); + expect(new TextDecoder().decode(files[0].data)).toBe(bytes('primary-a', authA)); + } finally { + window.bulwarkDesktop = undefined; + } + }); }); diff --git a/hooks/__tests__/use-new-mail-alerts.test.tsx b/hooks/__tests__/use-new-mail-alerts.test.tsx index c98b51dca..40c1fcee7 100644 --- a/hooks/__tests__/use-new-mail-alerts.test.tsx +++ b/hooks/__tests__/use-new-mail-alerts.test.tsx @@ -39,6 +39,14 @@ describe('new mail alerts', () => { vi.useRealTimers(); }); + it('in the desktop app leaves the sound to the notification path, so it plays once', () => { + window.bulwarkDesktop = { version: 1, platform: 'win32', os: {} } as never; + renderHook(() => useNewMailAlerts()); + arrive('a'); + expect(playNotificationSound).not.toHaveBeenCalled(); + window.bulwarkDesktop = undefined; + }); + it('tells screen readers once how many messages came, never what they say', () => { renderHook(() => useNewMailAlerts()); arrive('a'); diff --git a/hooks/use-app-badge.ts b/hooks/use-app-badge.ts index 927a869b5..d8a33b320 100644 --- a/hooks/use-app-badge.ts +++ b/hooks/use-app-badge.ts @@ -2,6 +2,8 @@ import { useEffect } from 'react'; import { debug } from '@/lib/debug'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { isPopoutWindow } from '@/lib/popout/window-role'; /** * Badges the *installed* PWA's icon — the shortcut Chrome/Edge pin next to @@ -35,6 +37,13 @@ export function useAppBadge(count: number, enabled = true): void { const effectiveCount = enabled ? count : 0; + // The desktop app's own icon. A pop-out has its own account and no say in it. + const desktopOs = getDesktopBridge()?.os; + if (desktopOs) { + if (!isPopoutWindow()) desktopOs.setBadge(effectiveCount); + return; + } + void (async () => { try { if (effectiveCount > 0) { diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts index 83cdce627..ce0047251 100644 --- a/hooks/use-attachment-drag.ts +++ b/hooks/use-attachment-drag.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, DragEvent } from "react"; +import { canDragNativeFiles, copyFilesToClipboard, startNativeFileDrag } from "@/lib/desktop/file-drag"; // Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on // drop to materialize a real file. Firefox and Safari ignore it, so we only @@ -40,6 +41,8 @@ export interface UseAttachmentDragResult { onPointerEnter: () => void; onDragStart: (e: DragEvent) => void; onDragEnd: (e: DragEvent) => void; + /** The desktop app: put the file on the clipboard, for pasting into Explorer or Finder. */ + onCopyAsFile?: () => Promise; } const NOOP_HANDLERS: UseAttachmentDragResult = { @@ -104,6 +107,13 @@ export function useAttachmentDrag( return; } + // In the desktop app the OS's own drag, with the real file on disk first. + if (canDragNativeFiles()) { + e.preventDefault(); + void startNativeFileDrag([{ name, url }]); + return; + } + // `DownloadURL` format: ::. The filename must be // raw - URL-encoding it lands literally on disk (`%20` instead of a // space). Callers are expected to sanitise reserved chars (`:` etc.) @@ -125,6 +135,11 @@ export function useAttachmentDrag( } }, []); + const copyAsFile = useCallback(async () => { + const url = urlRef.current ?? await source.getBlobUrl(); + return url ? copyFilesToClipboard([{ name: source.name || "download", url }]) : false; + }, [source]); + if (!enabled) return NOOP_HANDLERS; return { @@ -132,5 +147,6 @@ export function useAttachmentDrag( onPointerEnter: prefetch, onDragStart: handleDragStart, onDragEnd: handleDragEnd, + ...(canDragNativeFiles() ? { onCopyAsFile: copyAsFile } : {}), }; } diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index 25450a031..b60a4e910 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useUIStore } from "@/stores/ui-store"; import { isDragOutSupported } from "@/hooks/use-attachment-drag"; +import { canDragNativeFiles, startNativeFileDrag } from "@/lib/desktop/file-drag"; import { canExport } from "@/lib/device-policy"; import { appUrl, buildMailPath } from "@/lib/deep-links"; import { @@ -295,6 +296,13 @@ 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. + if (e.altKey && canDragNativeFiles()) { + e.preventDefault(); + void startNativeFileDrag([{ name, url }]); + return; + } // `DownloadURL` format: ::. Chromium expects // the filename raw - URL-encoding it ends up literally on disk // (e.g. `%20` instead of a space). The sanitiser already removed @@ -313,6 +321,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions)); const ready = getReadyBundle(routed, bundleOptions); if (ready) { + if (e.altKey && canDragNativeFiles()) { + e.preventDefault(); + void startNativeFileDrag([{ name: ready.name, url: ready.url }]); + return; + } e.dataTransfer.setData( "DownloadURL", `application/zip:${ready.name}:${ready.url}`, diff --git a/hooks/use-new-mail-alerts.ts b/hooks/use-new-mail-alerts.ts index 0b118372c..b2fd6801f 100644 --- a/hooks/use-new-mail-alerts.ts +++ b/hooks/use-new-mail-alerts.ts @@ -8,6 +8,7 @@ import { playNotificationSound } from "@/lib/notification-sound"; import { announce } from "@/lib/announce"; import { startTitleAlert } from "@/lib/title-alert"; import { debug } from "@/lib/debug"; +import { desktopPlaysMailSound } from "@/lib/desktop/mail-notifications"; /** * Mail that arrives within this time is told about once ("3 new messages"), @@ -51,7 +52,8 @@ export function useNewMailAlerts(): void { const s = useSettingsStore.getState(); if (s.doNotDisturb) return; if (batchTimer === null) { - if (s.emailNotificationsEnabled && s.emailNotificationSound) { + // The desktop app sounds it with the notification, in Do Not Disturb not at all. + if (s.emailNotificationsEnabled && s.emailNotificationSound && !desktopPlaysMailSound()) { playNotificationSound(s.notificationSoundChoice); } batchTimer = setTimeout(flush, NEW_MAIL_BATCH_MS); diff --git a/hooks/use-policy-refresh.ts b/hooks/use-policy-refresh.ts index 3ae942946..f7115e422 100644 --- a/hooks/use-policy-refresh.ts +++ b/hooks/use-policy-refresh.ts @@ -1,6 +1,7 @@ 'use client'; import { useEffect } from 'react'; +import { isDesktopApp } from '@/lib/desktop/bridge'; import { usePolicyStore } from '@/stores/policy-store'; /** How often an open, visible tab fetches the policy again. */ @@ -13,7 +14,9 @@ let mounted = 0; let interval: ReturnType | null = null; function refresh(force = false): void { - if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + // A browser tab out of view waits until it's back. The desktop app in the tray is + // out of view for days and still has to hear about a wipe or a changed policy. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden' && !isDesktopApp()) return; const now = Date.now(); if (!force && now - lastRefresh < FOCUS_THROTTLE_MS) return; const state = usePolicyStore.getState(); @@ -27,7 +30,7 @@ const onVisible = () => refresh(); /** * Fetches the policy again when the tab comes back into view and every 15 - * minutes while it is visible, so an announcement (or any other change the + * minutes while it is visible (in the desktop app, also while hidden), so an announcement (or any other change the * admin saves) reaches tabs that stay open. One timer for the page however * many components use it. */ diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts new file mode 100644 index 000000000..465180c64 --- /dev/null +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -0,0 +1,245 @@ +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 env = { leaveFor, push, notifyFailed }; +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('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'open', context: await context({ login: 'someone-else' }) }); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined); + stop(); + }); + + it('a click on a summary toast opens the Inbox of that login', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'open', context: await context({ emailId: undefined }) }); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox', slot: 3 }, undefined); + stop(); + }); + + it('junk in the context does no harm', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'archive', context: '{not json' }); + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined); + stop(); + }); +}); + +describe('listening', () => { + it('does nothing in a browser', async () => { + window.bulwarkDesktop = undefined; + const { startOsCommands } = await import('../os-commands'); + expect(() => startOsCommands(env)()).not.toThrow(); + }); + + it('subscribes, and carries out what arrives', async () => { + let listener: ((command: DesktopOsCommand) => void) | null = null; + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + os: { onCommand: (l: (command: DesktopOsCommand) => void) => { listener = l; return () => { listener = null; }; } }, + } as unknown as BulwarkDesktopBridge; + const { startOsCommands } = await import('../os-commands'); + const stop = startOsCommands(env); + listener!({ type: 'navigate', target: 'contacts' }); + await Promise.resolve(); + expect(leaveFor).toHaveBeenCalledWith('/contacts'); + stop(); + expect(listener).toBeNull(); + }); +}); diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts new file mode 100644 index 000000000..edfad4809 --- /dev/null +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopOsNotification } from '../types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +/** New mail to a native notification: which messages count, what the toast may say. */ + +const playNotificationSound = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/notification-sound', () => ({ playNotificationSound })); + +let pushWithoutContent = false; +vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent }) })); + +const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org', label: 'Work', email: 'alice@example.org' }]; +let client: IJMAPClient; +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: { + getState: () => ({ + activeAccountId: 'alice@one', + client, + getClientForAccount: () => client, + }), + }, +})); + +const INBOX = { id: 'in', role: 'inbox', name: 'Inbox' } as unknown as Mailbox; +const SENT = { id: 'out', role: 'sent', name: 'Sent' } as unknown as Mailbox; +const NEWS = { id: 'news', name: 'News' } as unknown as Mailbox; +const JUNK = { id: 'bad', role: 'junk', name: 'Junk' } as unknown as Mailbox; +const MAILBOXES = [INBOX, SENT, NEWS, JUNK]; + +function email(id: string, over: Partial = {}): Email { + return { + id, + threadId: `t-${id}`, + subject: `Secret ${id}`, + preview: 'preview text', + from: [{ name: 'Alice Sender', email: 'sender@example.org' }], + mailboxIds: { in: true }, + keywords: {}, + receivedAt: new Date().toISOString(), + ...over, + } as unknown as Email; +} + +function fakeClient(mail: Record, changes: { created: string[]; newState: string }[] = []) { + const queue = [...changes]; + return { + getAccountId: () => 'acc', + getAllMailboxes: vi.fn(async () => MAILBOXES), + getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0, state: 'seeded' })), + getEmailChanges: vi.fn(async (since: string) => { + const next = queue.shift(); + return next ? { oldState: since, newState: next.newState, created: next.created, updated: [], destroyed: [], hasMoreChanges: false } : null; + }), + getSomeEmails: vi.fn(async (ids: string[]) => ids.flatMap((id) => mail[id] ?? [])), + } as unknown as IJMAPClient; +} + +const labels = { + newMail: 'new_mail', + newMailCount: (count: number) => `count:${count}`, + noSubject: 'no_subject', + archive: 'archive', + markRead: 'mark_read', + delete: 'delete', + reply: 'reply', + replyPlaceholder: 'placeholder', +}; + +function setBridge(bridge: Partial | undefined) { + window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; +} + +beforeEach(async () => { + vi.clearAllMocks(); + pushWithoutContent = false; + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ + emailNotificationsEnabled: true, + emailNotificationSound: true, + doNotDisturb: false, + pushNotifyInboxOnly: true, + notificationMutes: { accounts: [], mailboxes: {} }, + notificationSoundChoice: 'involved', + }); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(null); +}); + +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('without a baseline the first push only sets one: old mail is not news', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({}, []); + const onMail = vi.fn(); + const watch = startNewMailWatch(onMail); + emitPushStateChange({ changed: { acc: { Email: 's1' } } } as never, fake, 'active'); + await flush(); + expect(fake.getEmailChanges).not.toHaveBeenCalled(); + expect(onMail).not.toHaveBeenCalled(); + watch.stop(); + }); + + it('ignores a change after it stopped', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]); + const onMail = vi.fn(); + const watch = startNewMailWatch(onMail); + watch.seed(fake); + await flush(); + watch.stop(); + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active'); + await flush(); + expect(onMail).not.toHaveBeenCalled(); + }); +}); + +describe('what the notification says', () => { + const one = (id = 'a', over: Partial = {}) => ({ client, accountId: 'acc', email: email(id, over) }); + + beforeEach(() => { + client = fakeClient({}); + }); + + it('sender, subject, the buttons, and what a button needs after a restart', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint'); + const notification = await buildMailNotification([one()], labels); + expect(notification.title).toBe('Alice Sender'); + expect(notification.body).toBe('Secret a'); + expect(notification.actions?.map((a) => a.id)).toEqual(['archive', 'mark-read', 'delete']); + expect(notification.reply?.label).toBe('reply'); + expect(JSON.parse(notification.context)).toEqual({ + accountId: 'acc', + slot: 3, + login: await loginFingerprint('https://one.example', 'alice@example.org'), + emailId: 'a', + threadId: 't-a', + }); + }); + + it('no Archive button where the organisation has it off', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const notification = await buildMailNotification([one()], { ...labels, archive: null }); + expect(notification.actions?.map((a) => a.id)).toEqual(['mark-read', 'delete']); + }); + + it('while the app is locked: no sender, no subject, no buttons', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(() => true); + const notification = await buildMailNotification([one()], labels); + const text = JSON.stringify(notification); + expect(notification.title).toBe('new_mail'); + expect(text).not.toContain('Alice Sender'); + expect(text).not.toContain('Secret'); + expect(notification.actions).toBeUndefined(); + expect(notification.reply).toBeUndefined(); + }); + + it('where the organisation keeps content off notifications: the same', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + pushWithoutContent = true; + const notification = await buildMailNotification([one()], labels); + expect(JSON.stringify(notification)).not.toContain('Secret'); + expect(JSON.stringify(notification)).not.toContain('Alice Sender'); + }); + + it('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 { noteStoreCreated, startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + const notify = vi.fn(async () => ({ shown: true, playSound: false })); + setBridge({ version: 1, platform: 'win32', os: { notify } as never }); + client = fakeClient({ a: email('a') }); + noteStoreCreated(client, 'acc', ['a']); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const mail = startMailNotifications(labels); + noteStoreCreated(client, 'acc', ['a']); + await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10); + await settle(); + expect(notify).toHaveBeenCalledTimes(1); + mail.stop(); + noteStoreCreated(client, 'acc', ['b']); + await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10); + await settle(); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('tells main the sound is off when the user has it off', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ emailNotificationSound: false }); + const notify = await arrive({ result: { shown: true, playSound: false } }); + expect(notify.mock.calls[0][0].sound).toBe(false); + }); +}); diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx new file mode 100644 index 000000000..301ee9abc --- /dev/null +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -0,0 +1,425 @@ +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */ + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => 'en', +})); + +function setBridge(bridge: Partial | undefined, platform: 'win32' | 'darwin' = 'win32') { + window.bulwarkDesktop = bridge ? ({ version: 1, platform, os: bridge } as unknown as BulwarkDesktopBridge) : undefined; +} + +afterEach(() => { + setBridge(undefined); + vi.unstubAllGlobals(); +}); + +describe('the badge', () => { + it('goes to the bridge in the app, and the Badging API is left alone', async () => { + const { useAppBadge } = await import('@/hooks/use-app-badge'); + const setBadge = vi.fn(); + const setAppBadge = vi.fn(async () => {}); + Object.defineProperty(navigator, 'setAppBadge', { value: setAppBadge, configurable: true }); + setBridge({ setBadge }); + const { rerender } = renderHook(({ count, enabled }) => useAppBadge(count, enabled), { initialProps: { count: 4, enabled: true } }); + expect(setBadge).toHaveBeenLastCalledWith(4); + rerender({ count: 4, enabled: false }); + expect(setBadge).toHaveBeenLastCalledWith(0); + expect(setAppBadge).not.toHaveBeenCalled(); + }); + + it('a pop-out has no say in it', async () => { + const { useAppBadge } = await import('@/hooks/use-app-badge'); + const role = await import('@/lib/popout/window-role'); + const popout = vi.spyOn(role, 'isPopoutWindow').mockReturnValue(true); + const setBadge = vi.fn(); + setBridge({ setBadge }); + renderHook(() => useAppBadge(4, true)); + expect(setBadge).not.toHaveBeenCalled(); + popout.mockRestore(); + }); +}); + +describe('new-mail sound', () => { + it('is left to the notification path in the app, not played twice', async () => { + const { desktopPlaysMailSound } = await import('../mail-notifications'); + expect(desktopPlaysMailSound()).toBe(false); + setBridge({ notify: vi.fn() as never }); + expect(desktopPlaysMailSound()).toBe(true); + }); +}); + +describe('the policy keeps refreshing', () => { + const fetchPolicy = vi.fn(async () => {}); + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }); + fetchPolicy.mockClear(); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy }); + const { resetPolicyRefresh } = await import('@/hooks/use-policy-refresh'); + resetPolicyRefresh(); + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (document as unknown as Record).visibilityState; + }); + + it('in the desktop app while hidden in the tray', async () => { + const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh'); + setBridge({}); + const { unmount } = renderHook(() => usePolicyRefresh()); + vi.advanceTimersByTime(POLICY_REFRESH_MS); + expect(fetchPolicy).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('but not in a browser tab out of view', async () => { + const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh'); + const { unmount } = renderHook(() => usePolicyRefresh()); + vi.advanceTimersByTime(POLICY_REFRESH_MS); + expect(fetchPolicy).not.toHaveBeenCalled(); + unmount(); + }); +}); + +function dropEvent(type: string, files: File[]): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } }); + return event; +} + +describe('files on the window', () => { + const file = new File(['x'], 'report.pdf'); + + beforeEach(async () => { + const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); + clearHandoffs(); + takeHandoff('compose'); + }); + + it('nothing happens in a browser', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + const stop = startWindowFiles(); + const event = dropEvent('drop', [file]); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + + it('a drop on the window starts a new draft with the file', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const over = dropEvent('dragover', [file]); + window.dispatchEvent(over); + expect(over.defaultPrevented).toBe(true); + const drop = dropEvent('drop', [file]); + window.dispatchEvent(drop); + expect(drop.defaultPrevented).toBe(true); + expect(takeHandoff('compose')?.files).toEqual([file]); + stop(); + }); + + it('a drop something else took is left alone', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const event = dropEvent('drop', [file]); + event.preventDefault(); + window.dispatchEvent(event); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + + it('text dragged around is not a file', async () => { + const { startWindowFiles } = await import('../window-files'); + setBridge({}); + const stop = startWindowFiles(); + const event = dropEvent('dragover', []); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + stop(); + }); + + it('an open composer takes the file itself', async () => { + const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const target = vi.fn(); + const clear = setComposerFileTarget(target); + window.dispatchEvent(dropEvent('drop', [file])); + expect(target).toHaveBeenCalledWith([file]); + expect(takeHandoff('compose')).toBeNull(); + 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 } }); + 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(); + }); + + 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 }; + + 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 Promise.resolve(); + await Promise.resolve(); + }); + return hook; + } + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3])))); + }); + + 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 a browser: still DownloadURL, and no copy button', async () => { + const hook = await ready(); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `application/pdf:report.pdf:${blobUrl}`); + expect(hook.result.current.onCopyAsFile).toBeUndefined(); + }); + + it('Copy as file hands the bytes to main', async () => { + const copyFiles = vi.fn(async () => true); + setBridge({ startDrag: vi.fn() as never, copyFiles }); + const hook = await ready(); + await expect(hook.result.current.onCopyAsFile?.()).resolves.toBe(true); + const [files] = copyFiles.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]]; + expect(files[0].name).toBe('report.pdf'); + }); +}); + +describe('waking up', () => { + async function started(os: Partial = {}) { + let fire: (event: DesktopOsPowerEvent) => void = () => {}; + setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os }); + const power = await import('../power'); + return { fire: (event: DesktopOsPowerEvent) => fire(event), power, 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 network change counts as waking', async () => { + const connection = new EventTarget(); + vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection })); + const { power, stop } = await started(); + const wake = vi.fn(); + const stopWake = power.addWakeHandler(wake); + await new Promise((resolve) => setTimeout(resolve, 0)); + connection.dispatchEvent(new Event('change')); + expect(wake).toHaveBeenCalled(); + stopWake(); + stop(); + }); +}); + +describe('the summary', () => { + const event = (id: string, start: string, over: Partial = {}) => ({ id, title: id, start, utcStart: start, status: 'confirmed', showWithoutTime: false, ...over }) as unknown as CalendarEvent; + const now = new Date('2026-11-03T10:00:00Z').getTime(); + + it('the next event is the soonest one still ahead, not cancelled and not all-day', async () => { + const { nextEventOf } = await import('../os-summary'); + const events = [ + event('later', '2026-11-03T15:00:00Z'), + event('past', '2026-11-03T09:00:00Z'), + event('cancelled', '2026-11-03T11:00:00Z', { status: 'cancelled' }), + event('allday', '2026-11-03T10:30:00Z', { showWithoutTime: true }), + event('soon', '2026-11-03T12:00:00Z'), + ]; + expect(nextEventOf(events, now)?.id).toBe('soon'); + expect(nextEventOf([event('past', '2026-11-03T09:00:00Z')], now)).toBeNull(); + }); + + describe('with a login', () => { + const row = { id: 'e1', threadId: 't1', subject: 'Lunch?', from: [{ name: 'Alice', email: 'a@example.org' }] }; + const getEmails = vi.fn(async () => ({ emails: [row], hasMore: false, total: 1 })); + const fake = { getAccountId: () => 'acc', getEmails, queryCalendarEvents: vi.fn(async () => []) }; + + beforeEach(async () => { + getEmails.mockClear(); + const { useAuthStore } = await import('@/stores/auth-store'); + const { useAccountStore } = await import('@/stores/account-store'); + const { useEmailStore } = await import('@/stores/email-store'); + useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' }); + useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never }); + useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never }); + }); + + 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('while locked it is empty, without asking anyone anything', async () => { + const { setAppLockCheck } = await import('../app-lock-state'); + const { readSummary, EMPTY_SUMMARY } = await import('../os-summary'); + setAppLockCheck(() => true); + await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY); + expect(getEmails).not.toHaveBeenCalled(); + }); + }); +}); + +describe('the settings section', () => { + const settings = { closeToTray: true, startAtLogin: false, startMinimized: false, attentionOnNewMail: true, quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null }; + const status = (over = {}): DesktopOsStatus => ({ settings: { ...settings, ...over }, pausedUntil: null, shortcutActive: false }); + + function bridgeFor(current: DesktopOsStatus) { + return { + getStatus: vi.fn(async () => current), + setSettings: vi.fn(async (patch: Partial) => ({ ...current, settings: { ...current.settings, ...patch } })), + onStatusChange: vi.fn(() => () => {}), + defaultAppStatus: vi.fn(async () => ({ registered: false, mailto: false, webcal: false })), + openDefaultAppSettings: vi.fn(async () => ({ registered: true, mailto: false, webcal: false })), + }; + } + + it('a browser never sees it', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('in the app it shows the state and changes it', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const os = bridgeFor(status()); + setBridge(os as never); + render(); + expect(await screen.findByText('section_title')).toBeInTheDocument(); + expect(screen.queryByText('start_minimized_label')).toBeNull(); + // Second in the section, after Close to tray. + fireEvent.click(screen.getAllByRole('switch')[1]); + await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ startAtLogin: true })); + expect(await screen.findByText('start_minimized_label')).toBeInTheDocument(); + }); + + it('the button offers the app and opens where the choice is made', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const os = bridgeFor(status()); + setBridge(os as never); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'default_app_button' })); + await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled()); + }); + + it('says when another app holds the shortcut', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never); + render(); + expect(await screen.findByText(/show_shortcut_taken/)).toBeInTheDocument(); + }); + + it('macOS gets its own word for the bounce', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + setBridge(bridgeFor(status()) as never, 'darwin'); + render(); + expect(await screen.findByText('attention_mac_label')).toBeInTheDocument(); + }); +}); diff --git a/lib/desktop/app-lock-state.ts b/lib/desktop/app-lock-state.ts new file mode 100644 index 000000000..72432d62b --- /dev/null +++ b/lib/desktop/app-lock-state.ts @@ -0,0 +1,19 @@ +/** + * Whether the app is locked, for everything that would show mail outside the app. + * + * App lock isn't built yet. When it is, its code calls setAppLockCheck once with a + * function that answers from its own state, and calls bridge.os.setLocked on every + * change: main then clears the tray, the jump list and delivered toasts, and strips + * anything that still comes through. Nothing else needs to change. Until then it + * answers false. + */ + +let check: (() => boolean) | null = null; + +export function setAppLockCheck(next: (() => boolean) | null): void { + check = next; +} + +export function isAppLocked(): boolean { + return check?.() === true; +} diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts new file mode 100644 index 000000000..c3f6476a7 --- /dev/null +++ b/lib/desktop/file-drag.ts @@ -0,0 +1,55 @@ +/** + * 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'; + +export interface DragFile { + name: string; + /** A blob: URL the page already holds. */ + url?: string; + data?: Uint8Array; +} + +export function canDragNativeFiles(): boolean { + return !!getDesktopBridge()?.os?.startDrag; +} + +async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> { + if (file.data) return { name: file.name, data: file.data }; + if (!file.url) throw new Error('A file needs its bytes or a URL'); + const response = await fetch(file.url); + return { name: file.name, data: new Uint8Array(await response.arrayBuffer()) }; +} + +/** + * Hands the file to main, which writes it to its private folder and starts the OS's + * drag. The mouse is still down, so this has to come straight from `dragstart`. + */ +export async function startNativeFileDrag(files: readonly DragFile[]): Promise { + const os = getDesktopBridge()?.os; + if (!os?.startDrag) return false; + setOwnDragActive(true); + try { + return await os.startDrag(await Promise.all(files.map(toBytes))); + } finally { + setOwnDragActive(false); + } +} + +/** Copy attachments as files, for pasting into Explorer or Finder. */ +export async function copyFilesToClipboard(files: readonly DragFile[]): Promise { + const os = getDesktopBridge()?.os; + if (!os?.copyFiles) return false; + return os.copyFiles(await Promise.all(files.map(toBytes))); +} + +/** + * The taskbar's progress bar, for an import, an export or a big send. 0 to 1, or null + * to take it away. Does nothing in a browser. + */ +export function setTaskbarProgress(progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error'): void { + getDesktopBridge()?.os?.setProgress(progress, mode); +} diff --git a/lib/desktop/mail-links.ts b/lib/desktop/mail-links.ts new file mode 100644 index 000000000..12416ef30 --- /dev/null +++ b/lib/desktop/mail-links.ts @@ -0,0 +1,44 @@ +/** + * Opening a message or folder from outside the mail page: a tray item, a toast, a jump + * list entry. The mail page applies a link the way it applies a permalink. If it isn't + * on screen, the link waits and the app goes there. + */ + +import type { MailDeepLink } from '@/lib/deep-links'; + +export interface MailLinkOptions { + replyText?: string; +} + +export type MailLinkOpener = (link: MailDeepLink, options?: MailLinkOptions) => void; + +let opener: MailLinkOpener | null = null; +let waiting: { link: MailDeepLink; options?: MailLinkOptions } | null = null; + +/** The mail page, while it's mounted. Takes over a link that was waiting for it. */ +export function setMailLinkOpener(next: MailLinkOpener): () => void { + opener = next; + if (waiting) { + const { link, options } = waiting; + waiting = null; + next(link, options); + } + return () => { + if (opener === next) opener = null; + }; +} + +export function openMailLink(link: MailDeepLink, options: MailLinkOptions | undefined, leaveFor: (path: '/') => void | Promise): void { + if (opener) { + opener(link, options); + return; + } + waiting = { link, options }; + void leaveFor('/'); +} + +/** For tests. */ +export function resetMailLinks(): void { + opener = null; + waiting = null; +} diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts new file mode 100644 index 000000000..090c9ebdf --- /dev/null +++ b/lib/desktop/mail-notifications.ts @@ -0,0 +1,184 @@ +/** + * 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 { 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 { useSettingsStore } from '@/stores/settings-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +export const MAIL_BATCH_MS = 3000; + +export interface MailNotificationLabels { + newMail: string; + newMailCount: (count: number) => string; + noSubject: string; + archive: string | null; + markRead: string; + delete: string; + reply: string; + replyPlaceholder: string; +} + +/** What a toast's button hands back, possibly after the app restarted. */ +export interface MailNotificationContext { + emailId?: string; + threadId?: string; + accountId?: string; + slot?: number; + login?: string; +} + +/** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */ +export function desktopPlaysMailSound(): boolean { + return !!getDesktopBridge()?.os && !isPopoutWindow(); +} + +interface Sender { + entryId: string | null; + slot?: number; + login?: string; + label: string | null; +} + +async function senderInfo(client: IJMAPClient): Promise { + const auth = useAuthStore.getState(); + const accounts = useAccountStore.getState().accounts; + const entry = accounts.find((account) => auth.getClientForAccount(account.id) === client) + ?? (auth.client === client ? accounts.find((account) => account.id === auth.activeAccountId) : undefined); + if (!entry) return { entryId: null, label: null }; + return { + entryId: entry.id, + slot: entry.cookieSlot, + login: await loginFingerprint(entry.serverUrl, entry.username), + label: accounts.length > 1 ? (entry.label || entry.email || entry.username || null) : null, + }; +} + +function withoutContent(sender: Sender): boolean { + if (isAppLocked()) return true; + // Unknown counts as on, as on the server's route. + if (!sender.entryId) return true; + try { + return devicesForAccount(sender.entryId).pushWithoutContent; + } catch { + return true; + } +} + +function senderName(mail: NewMail): string { + const first = mail.email.from?.[0]; + return first?.name || first?.email || ''; +} + +export async function buildMailNotification(batch: readonly NewMail[], labels: MailNotificationLabels): Promise { + const newest = batch.reduce((a, b) => (new Date(b.email.receivedAt ?? 0).getTime() > new Date(a.email.receivedAt ?? 0).getTime() ? b : a)); + const sender = await senderInfo(newest.client); + const hidden = withoutContent(sender); + const context: MailNotificationContext = { accountId: newest.accountId, slot: sender.slot, login: sender.login }; + const base = { + id: `mail:${newest.accountId}/${newest.email.id}`, + group: `mail:${newest.accountId}`, + sound: true, + attention: true, + }; + const suffix = sender.label && !hidden ? ` (${sender.label})` : ''; + if (batch.length > 1) { + return { + ...base, + title: `${labels.newMailCount(batch.length)}${suffix}`, + body: hidden ? '' : `${senderName(newest)}: ${newest.email.subject || labels.noSubject}`, + // Nothing for one message to act on: a click opens the Inbox. + context: JSON.stringify(context), + }; + } + const full: MailNotificationContext = { ...context, emailId: newest.email.id, threadId: newest.email.threadId }; + if (hidden) { + return { ...base, title: labels.newMail, body: '', context: JSON.stringify(full) }; + } + return { + ...base, + title: `${senderName(newest) || labels.newMail}${suffix}`, + body: newest.email.subject || newest.email.preview || labels.noSubject, + context: JSON.stringify(full), + actions: [ + ...(labels.archive ? [{ id: 'archive', label: labels.archive }] : []), + { id: 'mark-read', label: labels.markRead }, + { id: 'delete', label: labels.delete }, + ], + reply: { label: labels.reply, placeholder: labels.replyPlaceholder }, + }; +} + +export interface MailNotifications extends NewMailWatch { + setLabels(labels: MailNotificationLabels): void; +} + +let current: MailNotifications | null = null; + +/** + * The local store's `created` event, from lib/offline/offline-mode.ts's onEvent, once + * an account lives in the store: `noteStoreCreated(client, event.jmap, event.ids)`. + * Does nothing where notifications don't run: in a browser, in a pop-out. + */ +export function noteStoreCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void { + current?.noteCreated(client, accountId, ids); +} + +export function startMailNotifications(initial: MailNotificationLabels): MailNotifications { + let labels = initial; + let batch: NewMail[] = []; + let timer: ReturnType | null = null; + + const flush = () => { + timer = null; + const current = batch; + batch = []; + if (current.length === 0) return; + const settings = useSettingsStore.getState(); + if (!settings.emailNotificationsEnabled || settings.doNotDisturb) return; + void (async () => { + const notification = await buildMailNotification(current, labels); + const result = await getDesktopBridge()?.os?.notify({ ...notification, sound: settings.emailNotificationSound }); + if (result?.playSound) playNotificationSound(settings.notificationSoundChoice); + })().catch((error) => { + console.error('Failed to show a notification:', error); + }); + }; + + const watch = startNewMailWatch((mail) => { + batch.push(...mail); + timer ??= setTimeout(flush, MAIL_BATCH_MS); + }); + + const handle: MailNotifications = { + ...watch, + setLabels(next) { + labels = next; + }, + stop() { + watch.stop(); + if (timer) clearTimeout(timer); + timer = null; + if (current === handle) current = null; + }, + }; + current = handle; + return handle; +} diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts new file mode 100644 index 000000000..31a6568ea --- /dev/null +++ b/lib/desktop/new-mail.ts @@ -0,0 +1,149 @@ +/** + * Which messages count as new mail for the desktop app's notifications, for every + * connected login, whichever page or folder is open. + * + * With the local store the source is its `created` event (noteCreatedEmails): the + * engine has already worked out the delta. Without one, each login's push change is + * resolved here with Email/changes on a baseline of its own, the way + * reportNewInboxMail does for plugins. Both can see the same message; it's only told + * once. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { isMailboxMuted } from '@/lib/notification-mutes'; +import { subscribePushStateChanges } from '@/lib/push-events'; +import { useSettingsStore } from '@/stores/settings-store'; + +export interface NewMail { + client: IJMAPClient; + /** The JMAP account the message is in. */ + accountId: string; + email: Email; +} + +const MAX_PAGES = 5; +const MAX_CHANGES = 200; +const SEEN_LIMIT = 500; +/** An import or a restore creates old mail: nobody is waiting to hear about that. */ +const NEWEST_AGE_MS = 24 * 60 * 60 * 1000; +/** Folders mail lands in by itself, when not only the Inbox notifies. */ +const NOT_ARRIVALS = new Set(['junk', 'trash', 'sent', 'drafts', 'archive']); + +function mailboxIdsOf(email: Email): string[] { + return Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]); +} + +/** The folder a new message counts for, or null if it stays silent. */ +export function notifyingMailbox(email: Email, accountId: string, ownAccountId: string, mailboxes: readonly Mailbox[], now = Date.now()): Mailbox | null { + if (email.keywords?.$seen || email.keywords?.$draft) return null; + const received = email.receivedAt ? new Date(email.receivedAt).getTime() : now; + if (now - received > NEWEST_AGE_MS) return null; + const { notificationMutes, pushNotifyInboxOnly } = useSettingsStore.getState(); + const here = new Set(mailboxIdsOf(email)); + const mine = mailboxes.filter((mailbox) => (mailbox.accountId ?? ownAccountId) === accountId && here.has(mailbox.originalId || mailbox.id)); + const counts = mine.filter((mailbox) => (pushNotifyInboxOnly ? mailbox.role === 'inbox' : !NOT_ARRIVALS.has(mailbox.role ?? ''))); + // Mail filed in a muted folder and an unmuted one still notifies. + return counts.find((mailbox) => !isMailboxMuted(notificationMutes, accountId, mailbox.originalId || mailbox.id)) ?? null; +} + +export interface NewMailWatch { + /** The local store's `created` event for this login's account. */ + noteCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void; + /** Takes a state to work from, so the first push after the start can tell what's new. */ + seed(client: IJMAPClient): void; + stop(): void; +} + +export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWatch { + const baselines = new WeakMap>(); + const seen: string[] = []; + let queue: Promise = Promise.resolve(); + let stopped = false; + + const baselinesOf = (client: IJMAPClient) => { + let map = baselines.get(client); + if (!map) { + map = new Map(); + baselines.set(client, map); + } + return map; + }; + + const enqueue = (run: () => Promise) => { + const next = queue.then(run, run).catch((error) => { + console.error('Failed to check for new mail:', error); + }); + queue = next; + return next; + }; + + const report = async (client: IJMAPClient, accountId: string, ids: readonly string[]) => { + const fresh = ids.filter((id) => !seen.includes(`${accountId}/${id}`)); + if (fresh.length === 0 || stopped) return; + for (const id of fresh) seen.push(`${accountId}/${id}`); + seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT)); + const [emails, mailboxes] = await Promise.all([client.getSomeEmails(fresh, accountId), client.getAllMailboxes()]); + const own = client.getAccountId(); + const mail = emails + .filter((email) => notifyingMailbox(email, accountId, own, mailboxes)) + .map((email) => ({ client, accountId, email })); + if (mail.length > 0 && !stopped) onMail(mail); + }; + + const check = async (client: IJMAPClient, accountId: string, pushed: string) => { + const map = baselinesOf(client); + const since = map.get(accountId); + if (!since || !client.getEmailChanges) { + map.set(accountId, pushed); + return; + } + if (since === pushed) return; + const created: string[] = []; + let state = since; + for (let page = 0; page < MAX_PAGES; page += 1) { + const delta = await client.getEmailChanges(state, accountId, MAX_CHANGES); + if (!delta) { + // Too old for the server: start again from here. + map.set(accountId, pushed); + return; + } + created.push(...delta.created); + state = delta.newState; + if (!delta.hasMoreChanges) break; + } + map.set(accountId, state); + await report(client, accountId, created); + }; + + const stopPush = subscribePushStateChanges((change, client) => { + for (const [accountId, types] of Object.entries(change.changed ?? {})) { + const pushed = types?.Email; + if (pushed) void enqueue(() => check(client, accountId, pushed)); + } + }); + + return { + noteCreated(client, accountId, ids) { + void enqueue(() => report(client, accountId, ids)); + }, + seed(client) { + void enqueue(async () => { + const map = baselinesOf(client); + const own = client.getAccountId(); + const mailboxes = await client.getAllMailboxes(); + const inboxes = mailboxes.filter((mailbox) => mailbox.role === 'inbox'); + await Promise.all(inboxes.map(async (inbox) => { + const accountId = inbox.accountId ?? own; + if (map.has(accountId)) return; + const { state } = await client.getEmails(inbox.originalId || inbox.id, accountId, 1); + if (state && !map.has(accountId)) map.set(accountId, state); + })); + }); + }, + stop() { + stopped = true; + stopPush(); + }, + }; +} diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts new file mode 100644 index 000000000..8c7332225 --- /dev/null +++ b/lib/desktop/os-commands.ts @@ -0,0 +1,181 @@ +/** + * 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 { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { DesktopOsCommand } from '@/lib/desktop/types'; +import { openMailLink } from '@/lib/desktop/mail-links'; +import type { MailNotificationContext } from '@/lib/desktop/mail-notifications'; +import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff'; +import { parseMailto } from '@/lib/protocol-handlers/mailto'; +import { notifyPendingMailto, notifyPendingWebcal, savePendingMailto, savePendingWebcal } from '@/lib/protocol-handlers/session'; +import { parseWebcal } from '@/lib/protocol-handlers/webcal'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { runNotificationAction, type NotificationActionRequest, type NotificationActionStatus } from '@/lib/pwa-notify/page-actions'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; + +export interface OsCommandEnv { + /** Goes to a page, saving an open composer first. */ + leaveFor: (path: '/' | '/calendar' | '/contacts') => void | Promise; + /** A route change that isn't leaving anything behind. */ + push: (path: string) => void; + /** Says something couldn't be opened. */ + notifyFailed: (names: readonly string[]) => void; +} + +/** How long a toast's button waits for a cold start to sign in. */ +const SIGN_IN_WAIT_MS = 30_000; + +function parseContext(raw: string | undefined): MailNotificationContext { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed as MailNotificationContext : {}; + } catch { + return {}; + } +} + +function whenSignedIn(): Promise { + return new Promise((resolve) => { + if (useAuthStore.getState().isAuthenticated) { + resolve(true); + return; + } + const finish = (value: boolean) => { + clearTimeout(timer); + stop(); + resolve(value); + }; + const timer = setTimeout(() => finish(false), SIGN_IN_WAIT_MS); + const stop = useAuthStore.subscribe((state) => { + if (state.isAuthenticated) finish(true); + }); + }); +} + +/** A slot goes to whoever signs in next: a day-old toast has to still mean this login. */ +async function stillThatLogin(context: MailNotificationContext): Promise { + if (context.slot === undefined || !context.login) return false; + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot); + return !!entry && (await loginFingerprint(entry.serverUrl, entry.username)) === context.login; +} + +function showWindow(): void { + getDesktopBridge()?.windows?.focus?.(); +} + +async function openFromNotification(context: MailNotificationContext, env: OsCommandEnv, reply?: { text: string }): Promise { + showWindow(); + await whenSignedIn(); + const known = await stillThatLogin(context); + if (known && context.emailId) { + openMailLink({ kind: 'message', id: context.emailId, slot: context.slot, ownerAccountId: context.accountId, ...(reply ? { reply: true } : {}) }, reply ? { replyText: reply.text } : undefined, env.leaveFor); + return; + } + openMailLink({ kind: 'folder', ref: 'inbox', ...(known ? { slot: context.slot } : {}) }, undefined, env.leaveFor); +} + +function runAction(request: NotificationActionRequest): Promise { + return new Promise((resolve) => { + void runNotificationAction(request, (status) => { + if (status !== 'accepted') resolve(status); + }); + }); +} + +async function handleNotification(action: string, rawContext: string, reply: string | undefined, env: OsCommandEnv): Promise { + const context = parseContext(rawContext); + if (action === 'reply') { + await openFromNotification(context, env, { text: reply ?? '' }); + return; + } + if ((action === 'archive' || action === 'mark-read' || action === 'delete') && context.emailId && context.accountId) { + if (await whenSignedIn()) { + const status = await runAction({ + action, + emailId: context.emailId, + accountId: context.accountId, + threadId: context.threadId, + slot: context.slot, + login: context.login, + at: Date.now(), + }); + if (status === 'done') return; + } + } + // Clicked, or the button couldn't be carried out here: the message opens. + await openFromNotification(context, env); +} + +function handleFiles(command: Extract, env: OsCommandEnv): void { + const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name)); + const failed = [...command.failed]; + if (command.reason === 'attach') { + if (made.length > 0) handOffDraft({ files: made }); + } else { + for (const file of made) { + const kind = kindForFile(file); + if (kind) handOffFiles([file], kind); + else failed.push(file.name); + } + } + if (failed.length > 0) env.notifyFailed(failed); +} + +export function handleOsCommand(command: DesktopOsCommand, env: OsCommandEnv): void | Promise { + switch (command.type) { + case 'new-message': + handOffNewMessage(); + return; + case 'navigate': + if (command.target === 'inbox') openMailLink({ kind: 'folder', ref: 'inbox' }, undefined, env.leaveFor); + else void env.leaveFor(`/${command.target}`); + return; + case 'open-message': { + const context = parseContext(command.context); + if (context.slot !== undefined) return openFromNotification({ ...context, emailId: command.emailId }, env); + openMailLink({ kind: 'message', id: command.emailId, ownerAccountId: command.accountId }, undefined, env.leaveFor); + return; + } + case 'open-mailbox': + openMailLink({ kind: 'folder', ref: command.mailboxId }, undefined, env.leaveFor); + return; + case 'mailto': { + const parsed = parseMailto(command.url); + if (!parsed) return; + savePendingMailto(parsed); + notifyPendingMailto(); + env.push('/'); + return; + } + case 'webcal': { + const parsed = parseWebcal(command.url); + if (!parsed) return; + savePendingWebcal(parsed); + notifyPendingWebcal(); + env.push('/calendar'); + return; + } + case 'files': + handleFiles(command, env); + return; + case 'notification': + return handleNotification(command.action, command.context, command.reply, env); + } +} + +/** Listens for as long as the page lives. Commands wait in main until this runs. */ +export function startOsCommands(env: OsCommandEnv): () => void { + const os = getDesktopBridge()?.os; + if (!os) return () => {}; + return os.onCommand((command) => { + void Promise.resolve(handleOsCommand(command, env)).catch((error) => { + console.error('An OS command failed:', error); + }); + }); +} diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts new file mode 100644 index 000000000..12d014051 --- /dev/null +++ b/lib/desktop/os-summary.ts @@ -0,0 +1,130 @@ +/** + * 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 { isAppLocked } from '@/lib/desktop/app-lock-state'; +import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types'; +import { devicesForAccount } from '@/lib/device-policy'; +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'; + +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 { + if (isAppLocked()) return EMPTY_SUMMARY; + 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) return EMPTY_SUMMARY; + if (devicesForAccount(entry.id).pushWithoutContent) return EMPTY_SUMMARY; + + const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared); + let unread: DesktopOsTrayMessage[] = []; + let recent: DesktopOsTrayMessage[] = []; + if (inbox) { + const accountId = client.getAccountId(); + const login = await loginFingerprint(entry.serverUrl, entry.username); + const row = (email: Email): DesktopOsTrayMessage => ({ + emailId: email.id, + accountId, + sender: email.from?.[0]?.name || email.from?.[0]?.email || '', + subject: email.subject ?? '', + context: JSON.stringify({ emailId: email.id, threadId: email.threadId, accountId, slot: entry.cookieSlot, login }), + }); + const [unreadList, recentList] = await Promise.all([ + client.getEmails(inbox.originalId || inbox.id, undefined, ROWS, 0, undefined, false, { notKeyword: '$seen' }), + client.getEmails(inbox.originalId || inbox.id, undefined, ROWS), + ]); + unread = unreadList.emails.map(row); + recent = recentList.emails.map(row); + } + + let nextEvent: DesktopOsSummary['nextEvent'] = null; + if (useCalendarStore.getState().supportsCalendar) { + const events = await client.queryCalendarEvents({ + after: new Date(now).toISOString(), + before: new Date(now + EVENT_WINDOW_MS).toISOString(), + }).catch(() => [] as CalendarEvent[]); + const next = nextEventOf(events, now); + if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) }; + } + return { unread, recent, nextEvent }; +} + +/** Keeps main's copy current: when the unread count moves, and now and then for the next event. */ +export function startOsSummary(locale: () => string | undefined): { refresh: () => void; stop: () => void } { + const os = getDesktopBridge()?.os; + if (!os) return { refresh: () => {}, stop: () => {} }; + let timer: ReturnType | null = null; + let stale = false; + + const run = () => { + timer = null; + void readSummary(locale()).then((summary) => { + if (!stale) 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) refresh(); + }); + const interval = setInterval(refresh, REFRESH_EVERY_MS); + refresh(); + return { + refresh, + stop() { + stale = true; + stopEmail(); + stopAuth(); + clearInterval(interval); + if (timer) clearTimeout(timer); + }, + }; +} diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts new file mode 100644 index 000000000..84d5ccd58 --- /dev/null +++ b/lib/desktop/power.ts @@ -0,0 +1,70 @@ +/** + * 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 registers with addWakeHandler and addSleepHandler (setOnline, sync). + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { usePolicyStore } from '@/stores/policy-store'; + +type Handler = () => void; + +const wakeHandlers = new Set(); +const sleepHandlers = new Set(); +/** A resume and an unlock usually come together. */ +const WAKE_REPEAT_MS = 2000; + +export function addWakeHandler(handler: Handler): () => void { + wakeHandlers.add(handler); + return () => wakeHandlers.delete(handler); +} + +export function addSleepHandler(handler: Handler): () => void { + sleepHandlers.add(handler); + return () => sleepHandlers.delete(handler); +} + +function runAll(handlers: Set): void { + for (const handler of [...handlers]) { + try { + handler(); + } catch (error) { + console.error('A wake or sleep handler failed:', error); + } + } +} + +function wake(): void { + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent(new Event('online')); + const policy = usePolicyStore.getState(); + if (policy.loaded && !policy.fetching) void policy.fetchPolicy(); + runAll(wakeHandlers); +} + +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') runAll(sleepHandlers); + }); + // Wi-Fi to cable is a new network with no offline moment in between. + const connection = (navigator as unknown as { connection?: EventTarget }).connection; + connection?.addEventListener?.('change', wakeOnce); + return () => { + stopPower(); + connection?.removeEventListener?.('change', wakeOnce); + }; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index e049b727b..455ae5f1d 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -111,6 +111,122 @@ export interface DesktopPluginsBridge { install: (slug: string, version?: string) => Promise; } +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; +} + +export interface DesktopOsNotifyResult { + shown: boolean; + reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid'; + /** The page should play the sound the user picked. */ + playSound: boolean; +} + +export interface DesktopOsTrayMessage { + emailId: string; + accountId: string; + sender: string; + subject: string; + context?: string; +} + +export interface DesktopOsSummary { + unread: DesktopOsTrayMessage[]; + /** Newest conversations, whether read or not, for the jump list. */ + recent: DesktopOsTrayMessage[]; + nextEvent: { title: string; when: string } | null; +} + +export type DesktopOsPowerEvent = 'suspend' | 'resume' | 'lock-screen' | 'unlock-screen'; + +export interface DesktopOsDefaultAppStatus { + /** Offered to the OS as a mail app. */ + registered: boolean; + mailto: boolean; + webcal: boolean; +} + +export interface DesktopOsBridge { + /** + * What the OS asks for. Main holds these until the main window's page subscribes, + * so a cold start loses nothing. + */ + onCommand: (listener: (command: DesktopOsCommand) => void) => () => void; + getStatus: () => Promise; + setSettings: (patch: Partial) => Promise; + /** Pause notifications until this time, or null to end the pause. */ + setPaused: (until: number | null) => void; + onStatusChange: (listener: (status: DesktopOsStatus) => void) => () => void; + /** The unread count: overlay on Windows, setBadgeCount on macOS and Linux, the tray's dot. */ + setBadge: (count: number) => void; + /** What the tray and the jump list show. */ + setSummary: (summary: DesktopOsSummary) => void; + /** The taskbar's progress bar: lib/desktop/file-drag.ts setTaskbarProgress. */ + setProgress: (progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error') => void; + /** App lock's state: main clears the tray, jump list and toasts and strips what comes through. */ + setLocked: (locked: boolean) => void; + notify: (notification: DesktopOsNotification) => Promise; + onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void; + /** Writes the files to a private folder and starts a drag. Only with the mouse held. */ + startDrag: (files: DesktopOsFile[]) => Promise; + /** The same, onto the clipboard as files. */ + copyFiles: (files: DesktopOsFile[]) => Promise; + defaultAppStatus: () => Promise; + /** Offers the app to the OS and opens where the user chooses. */ + openDefaultAppSettings: () => Promise; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -120,6 +236,7 @@ export interface BulwarkDesktopBridge { menu?: DesktopMenuBridge; shell?: DesktopShellBridge; lifecycle?: DesktopLifecycleBridge; + os?: DesktopOsBridge; servers?: DesktopServersBridge; auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts new file mode 100644 index 000000000..c9b95221a --- /dev/null +++ b/lib/desktop/window-files.ts @@ -0,0 +1,66 @@ +/** + * 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'; + +type ComposerTarget = (files: File[]) => unknown; + +let composerTarget: ComposerTarget | null = null; +/** Our own native drag passing over our own window isn't a new file. */ +let ownDrag = false; + +/** The open composer takes files itself. No effect outside the desktop app: only this file reads it. */ +export function setComposerFileTarget(target: ComposerTarget): () => void { + composerTarget = target; + return () => { + if (composerTarget === target) composerTarget = null; + }; +} + +export function setOwnDragActive(active: boolean): void { + ownDrag = active; +} + +function take(files: File[]): void { + if (composerTarget) composerTarget(files); + else handOffDraft({ files }); +} + +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) return; + event.preventDefault(); + take(files); + }; + window.addEventListener('dragover', onDragOver); + window.addEventListener('drop', onDrop); + window.addEventListener('paste', onPaste); + return () => { + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('drop', onDrop); + window.removeEventListener('paste', onPaste); + }; +} diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts index 39287cd2f..c911eeae8 100644 --- a/lib/pwa-notify/__tests__/page-actions.test.ts +++ b/lib/pwa-notify/__tests__/page-actions.test.ts @@ -13,6 +13,7 @@ const makeClient = (accountId: string) => ({ getAccountId: () => accountId, getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { [id === 'binned' ? 'bin' : 'inbox']: true } })), getMailboxes: vi.fn(async () => [{ id: 'inbox', role: 'inbox' }, { id: 'x', originalId: 'bin', role: 'trash' }]), + batchMoveEmails: vi.fn(async (..._args: unknown[]) => {}), }); let clients: Record>; let activeAccountId = 'alice@one'; @@ -31,7 +32,9 @@ vi.mock('@/stores/auth-store', () => ({ }), }, })); -vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } })); +let listed: { id: string; sourceClientAccountId?: string }[] = []; +const deleteEmail = vi.fn(async (..._args: unknown[]) => true); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive, deleteEmail, emails: listed }) } })); // Each login's own subject, and a policy that reads it: the gate of the login // the notification is for, not of the one that's active here. vi.mock('@/lib/org/account-subjects', () => ({ subjectForAccount: (id: string) => ({ email: id, groups: [] }) })); @@ -43,6 +46,7 @@ vi.mock('@/stores/policy-store', () => ({ }, })); vi.mock('../plugin-veto', () => ({ vetoHooksRegistered: () => hooksRegistered })); +vi.mock('@/stores/settings-store', () => ({ useSettingsStore: { getState: () => ({ deleteAction: 'trash' }) } })); async function run(over: Partial = {}) { const request: NotificationActionRequest = { @@ -65,6 +69,7 @@ beforeEach(() => { activeAccountId = 'alice@one'; gateOff.clear(); hooksRegistered = false; + listed = []; batchMarkAsRead.mockResolvedValue(true); batchArchive.mockResolvedValue(true); }); @@ -278,3 +283,34 @@ describe('Reply', () => { removeCurrent(); }); }); + +describe("Delete, from the desktop app's toast", () => { + it('goes through the store when the message is in the list, with its hooks and the Delete setting', async () => { + listed = [{ id: 'e1' }]; + expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']); + expect(deleteEmail).toHaveBeenCalledWith(clients['alice@one'], 'e1'); + expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled(); + }); + + it("moves it to Trash by the folder's real id when it is not in the list", async () => { + expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']); + expect(clients['alice@one'].batchMoveEmails).toHaveBeenCalledWith(['e1'], 'bin', undefined, expect.any(Boolean)); + expect(deleteEmail).not.toHaveBeenCalled(); + }); + + it('plugins are never gone round: with a veto hook listening, the message opens instead', async () => { + hooksRegistered = true; + expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']); + expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled(); + }); + + it('a message since moved to Trash stays there', async () => { + clients['alice@one'].getEmail.mockResolvedValue({ id: 'e1', mailboxIds: { bin: true } } as never); + expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']); + expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled(); + }); + + it("the worker can't send one", () => { + expect(isNotificationActionRequest({ action: 'delete', emailId: 'e1', accountId: 'a', at: Date.now() })).toBe(false); + }); +}); diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts index 96b1db101..1368a61be 100644 --- a/lib/pwa-notify/page-actions.ts +++ b/lib/pwa-notify/page-actions.ts @@ -5,10 +5,12 @@ import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore } from '@/stores/policy-store'; import { loginFingerprint } from './login-fingerprint'; import { vetoHooksRegistered } from './plugin-veto'; +import { useSettingsStore } from '@/stores/settings-store'; /** What the service worker posts when a notification's button is clicked (public/sw.js). */ export interface NotificationActionRequest { - action: 'archive' | 'mark-read' | 'reply'; + /** `delete` only comes from the desktop app's toasts, never from the worker. */ + action: 'archive' | 'mark-read' | 'reply' | 'delete'; emailId: string; accountId: string; /** When the worker asked. */ @@ -149,6 +151,24 @@ export async function runNotificationAction( answer('failed'); return; } + if (request.action === 'delete') { + const store = useEmailStore.getState(); + // In the list: the store's own delete, with its hooks and the Delete setting. + if (store.emails.some((row) => row.id === request.emailId && (row.sourceClientAccountId ?? entry.id) === entry.id)) { + answer(await store.deleteEmail(client, request.emailId) ? 'done' : 'failed'); + return; + } + // Not in the list, so no store action can reach it, and plugins are never gone round. + const trash = mailboxes.find((mailbox) => mailbox.role === 'trash'); + if (!trash || !email || vetoHooksRegistered()) { + answer('failed'); + return; + } + const markRead = useSettingsStore.getState().deleteAction === 'trash-and-read'; + await client.batchMoveEmails([request.emailId], trash.originalId || trash.id, accountId, markRead); + answer('done'); + return; + } const done = request.action === 'mark-read' ? await useEmailStore.getState().batchMarkAsRead(activeClient, true, { emailIds: [request.emailId], client, accountId }) : await useEmailStore.getState().batchArchive(activeClient, { emailIds: [request.emailId], client, emails: email ? [email] : [], userArchive: true }); diff --git a/locales/ar/common.json b/locales/ar/common.json index 91db0f8ea..73529f9a4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6509,5 +6509,35 @@ "server_needed": "أدخل عنوان الخادم أولًا.", "server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.", "server_invalid": "عنوان الخادم هذا غير صالح." + }, + "desktopOs": { + "section_title": "تطبيق سطح المكتب", + "section_description": "طريقة عمل التطبيق مع حاسوبك.", + "close_to_tray_label": "الاستمرار في العمل في شريط النظام عند الإغلاق", + "close_to_tray_description": "يستمر وصول البريد الجديد والتذكيرات بعد إغلاق النافذة.", + "start_at_login_label": "البدء عند تسجيل الدخول إلى الحاسوب", + "start_at_login_description": "يبدأ التطبيق تلقائيًا عند تسجيل الدخول.", + "start_minimized_label": "البدء في شريط النظام", + "start_minimized_description": "لا تُفتح نافذة عند تسجيل الدخول. استخدم أيقونة شريط النظام لفتحه.", + "attention_label": "وميض شريط المهام عند وصول بريد جديد", + "attention_mac_label": "قفز أيقونة Dock عند وصول بريد جديد", + "attention_description": "فقط عندما لا يكون التطبيق في المقدمة.", + "quiet_hours_label": "ساعات الهدوء", + "quiet_hours_description": "لا إشعارات ولا أصوات في هذه الأوقات. يستمر وصول البريد.", + "quiet_hours_from": "من", + "quiet_hours_to": "إلى", + "show_shortcut_label": "إظهار التطبيق باختصار لوحة المفاتيح", + "show_shortcut_description": "{keys} يُظهر التطبيق في المقدمة من أي تطبيق آخر.", + "show_shortcut_taken": "يستخدم تطبيق آخر {keys}. أغلق ذلك التطبيق، أو أوقف هذا الخيار ثم شغّله مرة أخرى.", + "default_app_label": "تطبيق البريد الافتراضي", + "default_app_description": "فتح روابط البريد وملفات ‎.eml و‎.ics و‎.vcf بهذا التطبيق.", + "default_app_is_default": "هذا التطبيق يفتح روابط البريد لديك.", + "default_app_button": "اختيار التطبيق الافتراضي…", + "new_mail": "بريد جديد", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "نسخ كملف", + "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.", + "copy_failed": "تعذّر نسخ الملف." } } diff --git a/locales/ca/common.json b/locales/ca/common.json index d2e062a69..baea89184 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Primer escriu l'adreça del servidor.", "server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.", "server_invalid": "Aquesta adreça de servidor no és vàlida." + }, + "desktopOs": { + "section_title": "Aplicació d'escriptori", + "section_description": "Com funciona l'aplicació amb l'ordinador.", + "close_to_tray_label": "Continua a la safata en tancar", + "close_to_tray_description": "El correu nou i els recordatoris continuen arribant després de tancar la finestra.", + "start_at_login_label": "Inicia en entrar a l'ordinador", + "start_at_login_description": "L'aplicació s'inicia sola quan entreu.", + "start_minimized_label": "Inicia a la safata", + "start_minimized_description": "No s'obre cap finestra en entrar. Obriu-la amb la icona de la safata.", + "attention_label": "Fes parpellejar la barra de tasques amb correu nou", + "attention_mac_label": "Fes botar la icona del Dock amb correu nou", + "attention_description": "Només quan l'aplicació no és al davant.", + "quiet_hours_label": "Hores de silenci", + "quiet_hours_description": "Cap notificació ni so en aquestes hores. El correu continua arribant.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'aplicació amb una drecera de teclat", + "show_shortcut_description": "{keys} porta l'aplicació al davant des de qualsevol altra aplicació.", + "show_shortcut_taken": "Una altra aplicació fa servir {keys}. Tanqueu-la o desactiveu i torneu a activar això.", + "default_app_label": "Aplicació de correu predeterminada", + "default_app_description": "Obre els enllaços de correu i els fitxers .eml, .ics i .vcf amb aquesta aplicació.", + "default_app_is_default": "Aquesta aplicació obre els vostres enllaços de correu.", + "default_app_button": "Tria l'aplicació predeterminada…", + "new_mail": "Correu nou", + "no_subject": "(sense assumpte)", + "action_delete": "Suprimeix", + "copy_as_file": "Copia com a fitxer", + "copied": "Fitxer copiat. El podeu enganxar en una carpeta.", + "copy_failed": "No s'ha pogut copiar el fitxer." } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 6b1e41754..b6f1dfbaa 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Nejdřív zadejte adresu serveru.", "server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.", "server_invalid": "Tato adresa serveru není platná." + }, + "desktopOs": { + "section_title": "Desktopová aplikace", + "section_description": "Jak aplikace spolupracuje s vaším počítačem.", + "close_to_tray_label": "Po zavření běžet dál v oznamovací oblasti", + "close_to_tray_description": "Nová pošta a připomenutí chodí i po zavření okna.", + "start_at_login_label": "Spustit po přihlášení k počítači", + "start_at_login_description": "Aplikace se po přihlášení spustí sama.", + "start_minimized_label": "Spustit v oznamovací oblasti", + "start_minimized_description": "Po přihlášení se neotevře žádné okno. Otevřete ji ikonou v oznamovací oblasti.", + "attention_label": "Při nové poště blikat na hlavním panelu", + "attention_mac_label": "Při nové poště poskakovat ikonou v Docku", + "attention_description": "Jen když aplikace není v popředí.", + "quiet_hours_label": "Doba klidu", + "quiet_hours_description": "V tuto dobu žádná oznámení ani zvuky. Pošta dál přichází.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobrazit aplikaci klávesovou zkratkou", + "show_shortcut_description": "{keys} přenese aplikaci do popředí z jakékoli jiné aplikace.", + "show_shortcut_taken": "{keys} používá jiná aplikace. Zavřete ji, nebo tuto volbu vypněte a znovu zapněte.", + "default_app_label": "Výchozí poštovní aplikace", + "default_app_description": "Otevírat poštovní odkazy a soubory .eml, .ics a .vcf v této aplikaci.", + "default_app_is_default": "Tato aplikace otevírá vaše poštovní odkazy.", + "default_app_button": "Vybrat výchozí aplikaci…", + "new_mail": "Nová pošta", + "no_subject": "(bez předmětu)", + "action_delete": "Smazat", + "copy_as_file": "Kopírovat jako soubor", + "copied": "Soubor zkopírován. Můžete ho vložit do složky.", + "copy_failed": "Soubor se nepodařilo zkopírovat." } } diff --git a/locales/da/common.json b/locales/da/common.json index 61d6a1412..d9ccb53b3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Angiv først serverens adresse.", "server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.", "server_invalid": "Denne serveradresse er ikke gyldig." + }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Sådan arbejder appen sammen med din computer.", + "close_to_tray_label": "Fortsæt i systembakken ved lukning", + "close_to_tray_description": "Ny post og påmindelser bliver ved med at komme, når du lukker vinduet.", + "start_at_login_label": "Start, når du logger på computeren", + "start_at_login_description": "Appen starter selv, når du logger på.", + "start_minimized_label": "Start i systembakken", + "start_minimized_description": "Der åbnes intet vindue ved logon. Åbn appen med ikonet i systembakken.", + "attention_label": "Blink i proceslinjen ved ny post", + "attention_mac_label": "Lad Dock-ikonet hoppe ved ny post", + "attention_description": "Kun når appen ikke er forrest.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen notifikationer eller lyde i dette tidsrum. Post kommer stadig.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en tastaturgenvej", + "show_shortcut_description": "{keys} henter appen frem fra enhver anden app.", + "show_shortcut_taken": "En anden app bruger {keys}. Luk den app, eller slå dette fra og til igen.", + "default_app_label": "Standardmailapp", + "default_app_description": "Åbn maillinks og .eml-, .ics- og .vcf-filer med denne app.", + "default_app_is_default": "Denne app åbner dine maillinks.", + "default_app_button": "Vælg standardapp…", + "new_mail": "Ny post", + "no_subject": "(intet emne)", + "action_delete": "Slet", + "copy_as_file": "Kopiér som fil", + "copied": "Filen er kopieret. Du kan indsætte den i en mappe.", + "copy_failed": "Filen kunne ikke kopieres." } } diff --git a/locales/de/common.json b/locales/de/common.json index eb1ec9a29..1e6a972ed 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.", "server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.", "server_invalid": "Diese Serveradresse ist ungültig." + }, + "desktopOs": { + "section_title": "Desktop-App", + "section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.", + "close_to_tray_label": "Beim Schließen im Infobereich weiterlaufen", + "close_to_tray_description": "Neue E-Mails und Erinnerungen kommen auch dann an, wenn Sie das Fenster schließen.", + "start_at_login_label": "Beim Anmelden am Computer starten", + "start_at_login_description": "Die App startet von selbst, wenn Sie sich anmelden.", + "start_minimized_label": "Im Infobereich starten", + "start_minimized_description": "Beim Anmelden öffnet sich kein Fenster. Öffnen Sie die App über das Symbol im Infobereich.", + "attention_label": "Taskleiste bei neuen E-Mails blinken lassen", + "attention_mac_label": "Dock-Symbol bei neuen E-Mails hüpfen lassen", + "attention_description": "Nur wenn die App nicht im Vordergrund ist.", + "quiet_hours_label": "Ruhezeiten", + "quiet_hours_description": "In dieser Zeit keine Benachrichtigungen und Töne. E-Mails kommen trotzdem an.", + "quiet_hours_from": "Von", + "quiet_hours_to": "bis", + "show_shortcut_label": "App mit einer Tastenkombination anzeigen", + "show_shortcut_description": "{keys} holt die App aus jeder anderen App nach vorne.", + "show_shortcut_taken": "Eine andere App verwendet {keys}. Schließen Sie diese App oder schalten Sie das hier aus und wieder ein.", + "default_app_label": "Standard-E-Mail-App", + "default_app_description": "Mail-Links und .eml-, .ics- und .vcf-Dateien mit dieser App öffnen.", + "default_app_is_default": "Diese App öffnet Ihre Mail-Links.", + "default_app_button": "Standard-App auswählen…", + "new_mail": "Neue E-Mail", + "no_subject": "(kein Betreff)", + "action_delete": "Löschen", + "copy_as_file": "Als Datei kopieren", + "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.", + "copy_failed": "Die Datei konnte nicht kopiert werden." } } diff --git a/locales/en/common.json b/locales/en/common.json index 0af0e143f..450132227 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Enter your server's address first.", "server_declined": "You didn't allow this server, so nothing was sent to it.", "server_invalid": "This server address isn't valid." + }, + "desktopOs": { + "section_title": "Desktop app", + "section_description": "How the app works with your computer.", + "close_to_tray_label": "Keep running in the tray when closed", + "close_to_tray_description": "New mail and reminders keep coming after you close the window.", + "start_at_login_label": "Start when you sign in to your computer", + "start_at_login_description": "The app starts by itself when you sign in.", + "start_minimized_label": "Start in the tray", + "start_minimized_description": "No window opens at sign-in. Use the tray icon to open it.", + "attention_label": "Flash the taskbar on new mail", + "attention_mac_label": "Bounce the Dock icon on new mail", + "attention_description": "Only when the app is not in front.", + "quiet_hours_label": "Quiet hours", + "quiet_hours_description": "No notifications or sounds at these times. Mail still arrives.", + "quiet_hours_from": "From", + "quiet_hours_to": "to", + "show_shortcut_label": "Show the app with a keyboard shortcut", + "show_shortcut_description": "{keys} brings the app to the front, from any other app.", + "show_shortcut_taken": "Another app is using {keys}. Close that app, or turn this off and on again.", + "default_app_label": "Default mail app", + "default_app_description": "Open mail links and .eml, .ics and .vcf files with this app.", + "default_app_is_default": "This app opens your mail links.", + "default_app_button": "Choose default app…", + "new_mail": "New mail", + "no_subject": "(no subject)", + "action_delete": "Delete", + "copy_as_file": "Copy as file", + "copied": "File copied. You can paste it into a folder.", + "copy_failed": "The file could not be copied." } } diff --git a/locales/es/common.json b/locales/es/common.json index 83aeead77..e208908e9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Primero escribe la dirección del servidor.", "server_declined": "No permitiste este servidor, así que no se le envió nada.", "server_invalid": "Esta dirección de servidor no es válida." + }, + "desktopOs": { + "section_title": "Aplicación de escritorio", + "section_description": "Cómo funciona la aplicación con tu ordenador.", + "close_to_tray_label": "Seguir en la bandeja al cerrar", + "close_to_tray_description": "El correo nuevo y los recordatorios siguen llegando después de cerrar la ventana.", + "start_at_login_label": "Iniciar al entrar en el ordenador", + "start_at_login_description": "La aplicación se inicia sola cuando entras.", + "start_minimized_label": "Iniciar en la bandeja", + "start_minimized_description": "No se abre ninguna ventana al entrar. Ábrela con el icono de la bandeja.", + "attention_label": "Hacer parpadear la barra de tareas con correo nuevo", + "attention_mac_label": "Hacer rebotar el icono del Dock con correo nuevo", + "attention_description": "Solo cuando la aplicación no está delante.", + "quiet_hours_label": "Horas de silencio", + "quiet_hours_description": "Sin notificaciones ni sonidos en estas horas. El correo sigue llegando.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostrar la aplicación con un atajo de teclado", + "show_shortcut_description": "{keys} trae la aplicación al frente desde cualquier otra.", + "show_shortcut_taken": "Otra aplicación está usando {keys}. Ciérrala, o desactiva y vuelve a activar esta opción.", + "default_app_label": "Aplicación de correo predeterminada", + "default_app_description": "Abrir los enlaces de correo y los archivos .eml, .ics y .vcf con esta aplicación.", + "default_app_is_default": "Esta aplicación abre tus enlaces de correo.", + "default_app_button": "Elegir aplicación predeterminada…", + "new_mail": "Correo nuevo", + "no_subject": "(sin asunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como archivo", + "copied": "Archivo copiado. Puedes pegarlo en una carpeta.", + "copy_failed": "No se pudo copiar el archivo." } } diff --git a/locales/et/common.json b/locales/et/common.json index 788fb5171..709b73243 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Sisesta kõigepealt serveri aadress.", "server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.", "server_invalid": "See serveri aadress ei kehti." + }, + "desktopOs": { + "section_title": "Töölauarakendus", + "section_description": "Kuidas rakendus sinu arvutiga koos töötab.", + "close_to_tray_label": "Sulgemisel jätka salves", + "close_to_tray_description": "Uus kiri ja meeldetuletused saabuvad ka pärast akna sulgemist.", + "start_at_login_label": "Käivita arvutisse sisselogimisel", + "start_at_login_description": "Rakendus käivitub sisselogimisel ise.", + "start_minimized_label": "Käivita salves", + "start_minimized_description": "Sisselogimisel akent ei avata. Ava rakendus salve ikoonist.", + "attention_label": "Vilguta tegumiriba uue kirja korral", + "attention_mac_label": "Põrgata Docki ikooni uue kirja korral", + "attention_description": "Ainult siis, kui rakendus pole esiplaanil.", + "quiet_hours_label": "Vaikne aeg", + "quiet_hours_description": "Sel ajal teavitusi ega helisid pole. Kirjad saabuvad ikka.", + "quiet_hours_from": "Alates", + "quiet_hours_to": "kuni", + "show_shortcut_label": "Näita rakendust kiirklahviga", + "show_shortcut_description": "{keys} toob rakenduse esiplaanile igast teisest rakendusest.", + "show_shortcut_taken": "{keys} on teise rakenduse kasutuses. Sulge see rakendus või lülita see valik välja ja uuesti sisse.", + "default_app_label": "Vaikimisi e-posti rakendus", + "default_app_description": "Ava e-posti lingid ning .eml-, .ics- ja .vcf-failid selle rakendusega.", + "default_app_is_default": "See rakendus avab sinu e-posti lingid.", + "default_app_button": "Vali vaikerakendus…", + "new_mail": "Uus kiri", + "no_subject": "(teema puudub)", + "action_delete": "Kustuta", + "copy_as_file": "Kopeeri failina", + "copied": "Fail on kopeeritud. Saad selle kausta kleepida.", + "copy_failed": "Faili ei saanud kopeerida." } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 9eadf3199..7c5d654d8 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6509,5 +6509,35 @@ "server_needed": "ابتدا نشانی سرور را وارد کنید.", "server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.", "server_invalid": "این نشانی سرور معتبر نیست." + }, + "desktopOs": { + "section_title": "برنامه دسکتاپ", + "section_description": "نحوه کار برنامه با رایانه شما.", + "close_to_tray_label": "هنگام بستن در سینی سیستم اجرا بماند", + "close_to_tray_description": "نامه‌های جدید و یادآوری‌ها پس از بستن پنجره هم می‌رسند.", + "start_at_login_label": "هنگام ورود به رایانه شروع شود", + "start_at_login_description": "برنامه هنگام ورود خودش شروع می‌شود.", + "start_minimized_label": "شروع در سینی سیستم", + "start_minimized_description": "هنگام ورود پنجره‌ای باز نمی‌شود. با نماد سینی سیستم آن را باز کنید.", + "attention_label": "چشمک نوار وظیفه هنگام نامه جدید", + "attention_mac_label": "جهش نماد Dock هنگام نامه جدید", + "attention_description": "فقط وقتی برنامه در جلو نیست.", + "quiet_hours_label": "ساعات سکوت", + "quiet_hours_description": "در این ساعات اعلان و صدایی نیست. نامه‌ها همچنان می‌رسند.", + "quiet_hours_from": "از", + "quiet_hours_to": "تا", + "show_shortcut_label": "نمایش برنامه با میان‌بر صفحه‌کلید", + "show_shortcut_description": "{keys} برنامه را از هر برنامه دیگری به جلو می‌آورد.", + "show_shortcut_taken": "برنامه دیگری از {keys} استفاده می‌کند. آن را ببندید، یا این گزینه را خاموش و دوباره روشن کنید.", + "default_app_label": "برنامه پیش‌فرض ایمیل", + "default_app_description": "باز کردن پیوندهای ایمیل و پرونده‌های ‎.eml، ‎.ics و ‎.vcf با این برنامه.", + "default_app_is_default": "این برنامه پیوندهای ایمیل شما را باز می‌کند.", + "default_app_button": "انتخاب برنامه پیش‌فرض…", + "new_mail": "نامه جدید", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "رونوشت به‌صورت پرونده", + "copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.", + "copy_failed": "رونوشت پرونده ممکن نشد." } } diff --git a/locales/fr/common.json b/locales/fr/common.json index a44af9dbe..64eb990a1 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Saisissez d'abord l'adresse du serveur.", "server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.", "server_invalid": "Cette adresse de serveur n'est pas valide." + }, + "desktopOs": { + "section_title": "Application de bureau", + "section_description": "Comment l'application fonctionne avec votre ordinateur.", + "close_to_tray_label": "Rester dans la zone de notification à la fermeture", + "close_to_tray_description": "Le courrier et les rappels continuent d'arriver après la fermeture de la fenêtre.", + "start_at_login_label": "Démarrer à l'ouverture de session", + "start_at_login_description": "L'application démarre seule quand vous ouvrez votre session.", + "start_minimized_label": "Démarrer dans la zone de notification", + "start_minimized_description": "Aucune fenêtre ne s'ouvre à l'ouverture de session. Ouvrez-la avec l'icône de la zone de notification.", + "attention_label": "Faire clignoter la barre des tâches à l'arrivée de courrier", + "attention_mac_label": "Faire rebondir l'icône du Dock à l'arrivée de courrier", + "attention_description": "Seulement quand l'application n'est pas au premier plan.", + "quiet_hours_label": "Heures calmes", + "quiet_hours_description": "Ni notifications ni sons pendant ces heures. Le courrier continue d'arriver.", + "quiet_hours_from": "De", + "quiet_hours_to": "à", + "show_shortcut_label": "Afficher l'application avec un raccourci clavier", + "show_shortcut_description": "{keys} amène l'application au premier plan depuis n'importe quelle autre.", + "show_shortcut_taken": "Une autre application utilise {keys}. Fermez-la, ou désactivez puis réactivez cette option.", + "default_app_label": "Application de messagerie par défaut", + "default_app_description": "Ouvrir les liens de messagerie et les fichiers .eml, .ics et .vcf avec cette application.", + "default_app_is_default": "Cette application ouvre vos liens de messagerie.", + "default_app_button": "Choisir l'application par défaut…", + "new_mail": "Nouveau courrier", + "no_subject": "(sans objet)", + "action_delete": "Supprimer", + "copy_as_file": "Copier en tant que fichier", + "copied": "Fichier copié. Vous pouvez le coller dans un dossier.", + "copy_failed": "Le fichier n'a pas pu être copié." } } diff --git a/locales/he/common.json b/locales/he/common.json index 17580b9a8..18bb2fd87 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6510,5 +6510,35 @@ "server_needed": "קודם צריך להזין את כתובת השרת.", "server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.", "server_invalid": "כתובת השרת הזו לא תקינה." + }, + "desktopOs": { + "section_title": "יישום שולחן עבודה", + "section_description": "איך היישום עובד עם המחשב שלך.", + "close_to_tray_label": "להמשיך לפעול במגש המערכת בסגירה", + "close_to_tray_description": "דואר חדש ותזכורות ממשיכים להגיע אחרי סגירת החלון.", + "start_at_login_label": "להפעיל בכניסה למחשב", + "start_at_login_description": "היישום מופעל מעצמו כשנכנסים.", + "start_minimized_label": "להפעיל במגש המערכת", + "start_minimized_description": "בכניסה לא נפתח חלון. פותחים אותו מהסמל במגש המערכת.", + "attention_label": "הבהוב בשורת המשימות בדואר חדש", + "attention_mac_label": "קפיצת סמל ה-Dock בדואר חדש", + "attention_description": "רק כשהיישום לא בחזית.", + "quiet_hours_label": "שעות שקט", + "quiet_hours_description": "בשעות אלה אין התראות וצלילים. הדואר ממשיך להגיע.", + "quiet_hours_from": "מ-", + "quiet_hours_to": "עד", + "show_shortcut_label": "הצגת היישום בקיצור מקשים", + "show_shortcut_description": "{keys} מביא את היישום לחזית מכל יישום אחר.", + "show_shortcut_taken": "יישום אחר משתמש ב-{keys}. סגרו אותו, או כבו והפעילו מחדש את האפשרות הזו.", + "default_app_label": "יישום הדואר המוגדר כברירת מחדל", + "default_app_description": "פתיחת קישורי דואר וקובצי ‎.eml,‏ ‎.ics ו-‎.vcf ביישום זה.", + "default_app_is_default": "יישום זה פותח את קישורי הדואר שלך.", + "default_app_button": "בחירת יישום ברירת מחדל…", + "new_mail": "דואר חדש", + "no_subject": "(ללא נושא)", + "action_delete": "מחיקה", + "copy_as_file": "העתקה כקובץ", + "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.", + "copy_failed": "לא ניתן להעתיק את הקובץ." } } diff --git a/locales/hu/common.json b/locales/hu/common.json index 474772591..3a8863848 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Először adja meg a szerver címét.", "server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.", "server_invalid": "Ez a szervercím nem érvényes." + }, + "desktopOs": { + "section_title": "Asztali alkalmazás", + "section_description": "Hogyan működik az alkalmazás a számítógépével.", + "close_to_tray_label": "Bezáráskor fusson tovább a tálcán", + "close_to_tray_description": "Az új levelek és emlékeztetők az ablak bezárása után is megérkeznek.", + "start_at_login_label": "Indítás a számítógépre való bejelentkezéskor", + "start_at_login_description": "Az alkalmazás bejelentkezéskor magától elindul.", + "start_minimized_label": "Indítás a tálcán", + "start_minimized_description": "Bejelentkezéskor nem nyílik ablak. A tálcaikonnal nyithatja meg.", + "attention_label": "Tálca villogtatása új levélnél", + "attention_mac_label": "Dock-ikon ugráltatása új levélnél", + "attention_description": "Csak ha az alkalmazás nincs előtérben.", + "quiet_hours_label": "Csendes órák", + "quiet_hours_description": "Ekkor nincs értesítés és hang. A levelek továbbra is megérkeznek.", + "quiet_hours_from": "Ettől", + "quiet_hours_to": "eddig", + "show_shortcut_label": "Az alkalmazás megjelenítése billentyűparanccsal", + "show_shortcut_description": "{keys} bármely más alkalmazásból előtérbe hozza az alkalmazást.", + "show_shortcut_taken": "Egy másik alkalmazás használja ezt: {keys}. Zárja be azt, vagy kapcsolja ki és be ezt a beállítást.", + "default_app_label": "Alapértelmezett levelezőalkalmazás", + "default_app_description": "A levélhivatkozások és a .eml, .ics és .vcf fájlok megnyitása ezzel az alkalmazással.", + "default_app_is_default": "Ez az alkalmazás nyitja meg a levélhivatkozásait.", + "default_app_button": "Alapértelmezett alkalmazás kiválasztása…", + "new_mail": "Új levél", + "no_subject": "(nincs tárgy)", + "action_delete": "Törlés", + "copy_as_file": "Másolás fájlként", + "copied": "Fájl másolva. Beillesztheti egy mappába.", + "copy_failed": "A fájlt nem sikerült másolni." } } diff --git a/locales/it/common.json b/locales/it/common.json index 8cc39c364..b3e7964cd 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Inserisci prima l'indirizzo del server.", "server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.", "server_invalid": "Questo indirizzo del server non è valido." + }, + "desktopOs": { + "section_title": "App desktop", + "section_description": "Come l'app funziona con il tuo computer.", + "close_to_tray_label": "Resta nell'area di notifica alla chiusura", + "close_to_tray_description": "La nuova posta e i promemoria continuano ad arrivare dopo la chiusura della finestra.", + "start_at_login_label": "Avvia all'accesso al computer", + "start_at_login_description": "L'app si avvia da sola quando accedi.", + "start_minimized_label": "Avvia nell'area di notifica", + "start_minimized_description": "All'accesso non si apre alcuna finestra. Aprila con l'icona nell'area di notifica.", + "attention_label": "Fai lampeggiare la barra delle applicazioni con nuova posta", + "attention_mac_label": "Fai rimbalzare l'icona del Dock con nuova posta", + "attention_description": "Solo quando l'app non è in primo piano.", + "quiet_hours_label": "Ore di silenzio", + "quiet_hours_description": "Nessuna notifica o suono in queste ore. La posta continua ad arrivare.", + "quiet_hours_from": "Da", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'app con una scorciatoia da tastiera", + "show_shortcut_description": "{keys} porta l'app in primo piano da qualsiasi altra app.", + "show_shortcut_taken": "Un'altra app usa {keys}. Chiudila, oppure disattiva e riattiva questa opzione.", + "default_app_label": "App di posta predefinita", + "default_app_description": "Apri i link di posta e i file .eml, .ics e .vcf con questa app.", + "default_app_is_default": "Questa app apre i tuoi link di posta.", + "default_app_button": "Scegli app predefinita…", + "new_mail": "Nuova posta", + "no_subject": "(nessun oggetto)", + "action_delete": "Elimina", + "copy_as_file": "Copia come file", + "copied": "File copiato. Puoi incollarlo in una cartella.", + "copy_failed": "Impossibile copiare il file." } } diff --git a/locales/ja/common.json b/locales/ja/common.json index e051ceb66..e776616c2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6509,5 +6509,35 @@ "server_needed": "先にサーバーのアドレスを入力してください。", "server_declined": "このサーバーを許可しなかったため、何も送信していません。", "server_invalid": "このサーバーアドレスは正しくありません。" + }, + "desktopOs": { + "section_title": "デスクトップアプリ", + "section_description": "アプリとパソコンの連携方法です。", + "close_to_tray_label": "閉じてもトレイで実行を続ける", + "close_to_tray_description": "ウィンドウを閉じても新着メールとリマインダーは届きます。", + "start_at_login_label": "パソコンへのサインイン時に起動", + "start_at_login_description": "サインインするとアプリが自動で起動します。", + "start_minimized_label": "トレイで起動", + "start_minimized_description": "サインイン時にウィンドウは開きません。トレイのアイコンから開いてください。", + "attention_label": "新着メールでタスクバーを点滅", + "attention_mac_label": "新着メールで Dock アイコンを跳ねさせる", + "attention_description": "アプリが前面にないときのみ。", + "quiet_hours_label": "おやすみ時間", + "quiet_hours_description": "この時間は通知も音も出ません。メールは届きます。", + "quiet_hours_from": "開始", + "quiet_hours_to": "終了", + "show_shortcut_label": "キーボードショートカットでアプリを表示", + "show_shortcut_description": "{keys} で、ほかのアプリからこのアプリを前面に表示します。", + "show_shortcut_taken": "別のアプリが {keys} を使っています。そのアプリを閉じるか、この設定をオフにしてからオンに戻してください。", + "default_app_label": "既定のメールアプリ", + "default_app_description": "メールのリンクと .eml、.ics、.vcf ファイルをこのアプリで開きます。", + "default_app_is_default": "メールのリンクはこのアプリで開きます。", + "default_app_button": "既定のアプリを選択…", + "new_mail": "新着メール", + "no_subject": "(件名なし)", + "action_delete": "削除", + "copy_as_file": "ファイルとしてコピー", + "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。", + "copy_failed": "ファイルをコピーできませんでした。" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index e7f7be907..d589f30e9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6509,5 +6509,35 @@ "server_needed": "먼저 서버 주소를 입력하세요.", "server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.", "server_invalid": "이 서버 주소는 올바르지 않습니다." + }, + "desktopOs": { + "section_title": "데스크톱 앱", + "section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.", + "close_to_tray_label": "닫을 때 트레이에서 계속 실행", + "close_to_tray_description": "창을 닫아도 새 메일과 리마인더가 계속 도착합니다.", + "start_at_login_label": "컴퓨터에 로그인할 때 시작", + "start_at_login_description": "로그인하면 앱이 자동으로 시작됩니다.", + "start_minimized_label": "트레이에서 시작", + "start_minimized_description": "로그인할 때 창이 열리지 않습니다. 트레이 아이콘으로 여세요.", + "attention_label": "새 메일이 오면 작업 표시줄 깜박이기", + "attention_mac_label": "새 메일이 오면 Dock 아이콘 튀기기", + "attention_description": "앱이 앞에 있지 않을 때만 작동합니다.", + "quiet_hours_label": "방해 금지 시간", + "quiet_hours_description": "이 시간에는 알림과 소리가 없습니다. 메일은 계속 도착합니다.", + "quiet_hours_from": "시작", + "quiet_hours_to": "종료", + "show_shortcut_label": "키보드 단축키로 앱 표시", + "show_shortcut_description": "{keys} 키로 다른 앱에서도 이 앱을 앞으로 가져옵니다.", + "show_shortcut_taken": "다른 앱이 {keys} 키를 사용 중입니다. 그 앱을 닫거나 이 옵션을 껐다가 다시 켜세요.", + "default_app_label": "기본 메일 앱", + "default_app_description": "메일 링크와 .eml, .ics, .vcf 파일을 이 앱으로 엽니다.", + "default_app_is_default": "이 앱이 메일 링크를 엽니다.", + "default_app_button": "기본 앱 선택…", + "new_mail": "새 메일", + "no_subject": "(제목 없음)", + "action_delete": "삭제", + "copy_as_file": "파일로 복사", + "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.", + "copy_failed": "파일을 복사할 수 없습니다." } } diff --git a/locales/lv/common.json b/locales/lv/common.json index c07fe9c31..22c93c057 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Vispirms ievadiet servera adresi.", "server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.", "server_invalid": "Šī servera adrese nav derīga." + }, + "desktopOs": { + "section_title": "Darbvirsmas lietotne", + "section_description": "Kā lietotne darbojas ar jūsu datoru.", + "close_to_tray_label": "Aizverot turpināt darbu teknē", + "close_to_tray_description": "Jauns pasts un atgādinājumi turpina pienākt arī pēc loga aizvēršanas.", + "start_at_login_label": "Sākt, piesakoties datorā", + "start_at_login_description": "Lietotne sākas pati, kad piesakāties.", + "start_minimized_label": "Sākt teknē", + "start_minimized_description": "Piesakoties logs neatveras. Atveriet to ar teknes ikonu.", + "attention_label": "Mirgot uzdevumjoslā, pienākot pastam", + "attention_mac_label": "Lēkāt Dock ikonai, pienākot pastam", + "attention_description": "Tikai tad, ja lietotne nav priekšplānā.", + "quiet_hours_label": "Klusuma stundas", + "quiet_hours_description": "Šajā laikā nav paziņojumu un skaņu. Pasts turpina pienākt.", + "quiet_hours_from": "No", + "quiet_hours_to": "līdz", + "show_shortcut_label": "Rādīt lietotni ar īsinājumtaustiņu", + "show_shortcut_description": "{keys} parāda lietotni priekšplānā no jebkuras citas lietotnes.", + "show_shortcut_taken": "{keys} izmanto cita lietotne. Aizveriet to vai izslēdziet un atkal ieslēdziet šo iestatījumu.", + "default_app_label": "Noklusējuma pasta lietotne", + "default_app_description": "Atvērt pasta saites un .eml, .ics un .vcf failus ar šo lietotni.", + "default_app_is_default": "Šī lietotne atver jūsu pasta saites.", + "default_app_button": "Izvēlēties noklusējuma lietotni…", + "new_mail": "Jauns pasts", + "no_subject": "(nav tēmas)", + "action_delete": "Dzēst", + "copy_as_file": "Kopēt kā failu", + "copied": "Fails nokopēts. Varat to ielīmēt mapē.", + "copy_failed": "Failu neizdevās nokopēt." } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 4633a5d6d..3d8bb9a5d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Эхлээд серверийн хаягаа оруулна уу.", "server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.", "server_invalid": "Энэ серверийн хаяг буруу байна." + }, + "desktopOs": { + "section_title": "Ширээний апп", + "section_description": "Апп таны компьютертэй хэрхэн ажиллах.", + "close_to_tray_label": "Хаахад тавиур дээр үргэлжлүүлэн ажиллах", + "close_to_tray_description": "Цонхыг хаасны дараа ч шинэ захиа, сануулга ирсээр байна.", + "start_at_login_label": "Компьютерт нэвтрэхэд эхлүүлэх", + "start_at_login_description": "Нэвтрэхэд апп өөрөө эхэлнэ.", + "start_minimized_label": "Тавиур дээр эхлүүлэх", + "start_minimized_description": "Нэвтрэхэд цонх нээгдэхгүй. Тавиурын дүрсээр нээнэ үү.", + "attention_label": "Шинэ захиа ирэхэд ажлын самбарыг анивчуулах", + "attention_mac_label": "Шинэ захиа ирэхэд Dock дүрсийг үсрүүлэх", + "attention_description": "Зөвхөн апп урд талд байхгүй үед.", + "quiet_hours_label": "Чимээгүй цаг", + "quiet_hours_description": "Энэ үед мэдэгдэл, дуу гарахгүй. Захиа ирсээр байна.", + "quiet_hours_from": "Эхлэх", + "quiet_hours_to": "хүртэл", + "show_shortcut_label": "Гарын товчлолоор аппыг харуулах", + "show_shortcut_description": "{keys} нь аль ч аппаас энэ аппыг урд гаргана.", + "show_shortcut_taken": "{keys}-г өөр апп ашиглаж байна. Тэр аппыг хаах, эсвэл үүнийг унтраагаад дахин асаана уу.", + "default_app_label": "Үндсэн шуудангийн апп", + "default_app_description": "Шуудангийн холбоос болон .eml, .ics, .vcf файлыг энэ аппаар нээх.", + "default_app_is_default": "Энэ апп таны шуудангийн холбоосыг нээдэг.", + "default_app_button": "Үндсэн апп сонгох…", + "new_mail": "Шинэ захиа", + "no_subject": "(гарчиггүй)", + "action_delete": "Устгах", + "copy_as_file": "Файл болгож хуулах", + "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.", + "copy_failed": "Файлыг хуулж чадсангүй." } } diff --git a/locales/nb/common.json b/locales/nb/common.json index d2e1a518e..d4d7099ad 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Skriv inn serveradressen først.", "server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.", "server_invalid": "Denne serveradressen er ikke gyldig." + }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Slik samarbeider appen med datamaskinen din.", + "close_to_tray_label": "Fortsett i systemstatusfeltet ved lukking", + "close_to_tray_description": "Ny e-post og påminnelser fortsetter å komme etter at du lukker vinduet.", + "start_at_login_label": "Start når du logger på datamaskinen", + "start_at_login_description": "Appen starter av seg selv når du logger på.", + "start_minimized_label": "Start i systemstatusfeltet", + "start_minimized_description": "Det åpnes ikke noe vindu ved pålogging. Åpne appen med ikonet i systemstatusfeltet.", + "attention_label": "Blink i oppgavelinjen ved ny e-post", + "attention_mac_label": "La Dock-ikonet sprette ved ny e-post", + "attention_description": "Bare når appen ikke er foran.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen varsler eller lyder i dette tidsrommet. E-post kommer fortsatt.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en hurtigtast", + "show_shortcut_description": "{keys} henter appen frem fra hvilken som helst annen app.", + "show_shortcut_taken": "En annen app bruker {keys}. Lukk den appen, eller slå dette av og på igjen.", + "default_app_label": "Standard e-postapp", + "default_app_description": "Åpne e-postlenker og .eml-, .ics- og .vcf-filer med denne appen.", + "default_app_is_default": "Denne appen åpner e-postlenkene dine.", + "default_app_button": "Velg standardapp…", + "new_mail": "Ny e-post", + "no_subject": "(uten emne)", + "action_delete": "Slett", + "copy_as_file": "Kopier som fil", + "copied": "Filen er kopiert. Du kan lime den inn i en mappe.", + "copy_failed": "Filen kunne ikke kopieres." } } diff --git a/locales/nl/common.json b/locales/nl/common.json index b704a66fc..12301289c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Vul eerst het adres van de server in.", "server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.", "server_invalid": "Dit serveradres is niet geldig." + }, + "desktopOs": { + "section_title": "Desktop-app", + "section_description": "Hoe de app samenwerkt met je computer.", + "close_to_tray_label": "Bij sluiten doorgaan in het systeemvak", + "close_to_tray_description": "Nieuwe mail en herinneringen blijven komen nadat je het venster sluit.", + "start_at_login_label": "Starten als je je aanmeldt op de computer", + "start_at_login_description": "De app start vanzelf als je je aanmeldt.", + "start_minimized_label": "Starten in het systeemvak", + "start_minimized_description": "Bij het aanmelden opent geen venster. Open de app met het pictogram in het systeemvak.", + "attention_label": "Taakbalk laten knipperen bij nieuwe mail", + "attention_mac_label": "Dock-pictogram laten stuiteren bij nieuwe mail", + "attention_description": "Alleen als de app niet vooraan staat.", + "quiet_hours_label": "Stille uren", + "quiet_hours_description": "Geen meldingen of geluiden op deze tijden. Mail blijft binnenkomen.", + "quiet_hours_from": "Van", + "quiet_hours_to": "tot", + "show_shortcut_label": "App tonen met een sneltoets", + "show_shortcut_description": "{keys} haalt de app naar voren vanuit elke andere app.", + "show_shortcut_taken": "Een andere app gebruikt {keys}. Sluit die app, of zet dit uit en weer aan.", + "default_app_label": "Standaard-mailapp", + "default_app_description": "Maillinks en .eml-, .ics- en .vcf-bestanden openen met deze app.", + "default_app_is_default": "Deze app opent je maillinks.", + "default_app_button": "Standaard-app kiezen…", + "new_mail": "Nieuwe mail", + "no_subject": "(geen onderwerp)", + "action_delete": "Verwijderen", + "copy_as_file": "Kopiëren als bestand", + "copied": "Bestand gekopieerd. Je kunt het in een map plakken.", + "copy_failed": "Het bestand kon niet worden gekopieerd." } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 8ccc30784..6b31fdcf1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Najpierw wpisz adres serwera.", "server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.", "server_invalid": "Ten adres serwera jest nieprawidłowy." + }, + "desktopOs": { + "section_title": "Aplikacja na komputer", + "section_description": "Jak aplikacja współpracuje z Twoim komputerem.", + "close_to_tray_label": "Po zamknięciu działaj dalej w zasobniku", + "close_to_tray_description": "Nowa poczta i przypomnienia docierają też po zamknięciu okna.", + "start_at_login_label": "Uruchamiaj po zalogowaniu do komputera", + "start_at_login_description": "Aplikacja uruchamia się sama po zalogowaniu.", + "start_minimized_label": "Uruchamiaj w zasobniku", + "start_minimized_description": "Po zalogowaniu nie otwiera się okno. Otwórz je ikoną w zasobniku.", + "attention_label": "Migaj na pasku zadań przy nowej poczcie", + "attention_mac_label": "Podskakuj ikoną w Docku przy nowej poczcie", + "attention_description": "Tylko gdy aplikacja nie jest na wierzchu.", + "quiet_hours_label": "Godziny ciszy", + "quiet_hours_description": "W tym czasie bez powiadomień i dźwięków. Poczta nadal dociera.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Pokazuj aplikację skrótem klawiszowym", + "show_shortcut_description": "{keys} przywołuje aplikację z każdej innej aplikacji.", + "show_shortcut_taken": "Skrót {keys} jest używany przez inną aplikację. Zamknij ją albo wyłącz i włącz tę opcję.", + "default_app_label": "Domyślna aplikacja poczty", + "default_app_description": "Otwieraj linki pocztowe i pliki .eml, .ics i .vcf w tej aplikacji.", + "default_app_is_default": "Ta aplikacja otwiera Twoje linki pocztowe.", + "default_app_button": "Wybierz domyślną aplikację…", + "new_mail": "Nowa poczta", + "no_subject": "(bez tematu)", + "action_delete": "Usuń", + "copy_as_file": "Kopiuj jako plik", + "copied": "Plik skopiowany. Możesz wkleić go do folderu.", + "copy_failed": "Nie udało się skopiować pliku." } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 188b256f8..ed181e935 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Digite primeiro o endereço do servidor.", "server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.", "server_invalid": "Este endereço de servidor não é válido." + }, + "desktopOs": { + "section_title": "Aplicação de ambiente de trabalho", + "section_description": "Como a aplicação funciona com o seu computador.", + "close_to_tray_label": "Continuar na bandeja ao fechar", + "close_to_tray_description": "O correio novo e os lembretes continuam a chegar depois de fechar a janela.", + "start_at_login_label": "Iniciar ao entrar no computador", + "start_at_login_description": "A aplicação inicia sozinha quando entra.", + "start_minimized_label": "Iniciar na bandeja", + "start_minimized_description": "Não abre nenhuma janela ao entrar. Abra-a com o ícone da bandeja.", + "attention_label": "Piscar a barra de tarefas com correio novo", + "attention_mac_label": "Saltar o ícone da Dock com correio novo", + "attention_description": "Só quando a aplicação não está à frente.", + "quiet_hours_label": "Horas de silêncio", + "quiet_hours_description": "Sem notificações nem sons nestas horas. O correio continua a chegar.", + "quiet_hours_from": "De", + "quiet_hours_to": "até", + "show_shortcut_label": "Mostrar a aplicação com um atalho de teclado", + "show_shortcut_description": "{keys} traz a aplicação para a frente a partir de qualquer outra.", + "show_shortcut_taken": "Outra aplicação está a usar {keys}. Feche-a, ou desative e volte a ativar esta opção.", + "default_app_label": "Aplicação de correio predefinida", + "default_app_description": "Abrir ligações de correio e ficheiros .eml, .ics e .vcf com esta aplicação.", + "default_app_is_default": "Esta aplicação abre as suas ligações de correio.", + "default_app_button": "Escolher aplicação predefinida…", + "new_mail": "Correio novo", + "no_subject": "(sem assunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como ficheiro", + "copied": "Ficheiro copiado. Pode colá-lo numa pasta.", + "copy_failed": "Não foi possível copiar o ficheiro." } } diff --git a/locales/ro/common.json b/locales/ro/common.json index ae31839c2..fb2ca2d1f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Introdu mai întâi adresa serverului.", "server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.", "server_invalid": "Această adresă de server nu este validă." + }, + "desktopOs": { + "section_title": "Aplicație desktop", + "section_description": "Cum funcționează aplicația cu computerul tău.", + "close_to_tray_label": "Rămâne în zona de notificări la închidere", + "close_to_tray_description": "E-mailurile noi și mementourile continuă să sosească după închiderea ferestrei.", + "start_at_login_label": "Pornește la conectarea în computer", + "start_at_login_description": "Aplicația pornește singură când te conectezi.", + "start_minimized_label": "Pornește în zona de notificări", + "start_minimized_description": "La conectare nu se deschide nicio fereastră. Deschide-o din pictograma din zona de notificări.", + "attention_label": "Clipește în bara de activități la e-mail nou", + "attention_mac_label": "Sare pictograma din Dock la e-mail nou", + "attention_description": "Doar când aplicația nu este în față.", + "quiet_hours_label": "Ore de liniște", + "quiet_hours_description": "Fără notificări sau sunete în aceste ore. E-mailurile sosesc în continuare.", + "quiet_hours_from": "De la", + "quiet_hours_to": "până la", + "show_shortcut_label": "Arată aplicația cu o comandă rapidă de la tastatură", + "show_shortcut_description": "{keys} aduce aplicația în față din orice altă aplicație.", + "show_shortcut_taken": "Altă aplicație folosește {keys}. Închide-o sau dezactivează și reactivează această opțiune.", + "default_app_label": "Aplicația de e-mail implicită", + "default_app_description": "Deschide linkurile de e-mail și fișierele .eml, .ics și .vcf cu această aplicație.", + "default_app_is_default": "Această aplicație îți deschide linkurile de e-mail.", + "default_app_button": "Alege aplicația implicită…", + "new_mail": "E-mail nou", + "no_subject": "(fără subiect)", + "action_delete": "Șterge", + "copy_as_file": "Copiază ca fișier", + "copied": "Fișier copiat. Îl poți lipi într-un dosar.", + "copy_failed": "Fișierul nu a putut fi copiat." } } diff --git a/locales/ru/common.json b/locales/ru/common.json index a9ee1c65c..1661ae3ca 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Сначала введите адрес сервера.", "server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.", "server_invalid": "Этот адрес сервера недействителен." + }, + "desktopOs": { + "section_title": "Настольное приложение", + "section_description": "Как приложение работает с вашим компьютером.", + "close_to_tray_label": "При закрытии продолжать работу в трее", + "close_to_tray_description": "Новая почта и напоминания приходят и после закрытия окна.", + "start_at_login_label": "Запускать при входе в систему", + "start_at_login_description": "Приложение запускается само, когда вы входите.", + "start_minimized_label": "Запускать в трее", + "start_minimized_description": "При входе окно не открывается. Откройте его значком в трее.", + "attention_label": "Мигать на панели задач при новой почте", + "attention_mac_label": "Подпрыгивать значком в Dock при новой почте", + "attention_description": "Только когда приложение не на переднем плане.", + "quiet_hours_label": "Тихие часы", + "quiet_hours_description": "В это время нет уведомлений и звуков. Почта продолжает приходить.", + "quiet_hours_from": "С", + "quiet_hours_to": "до", + "show_shortcut_label": "Показывать приложение сочетанием клавиш", + "show_shortcut_description": "{keys} выводит приложение на передний план из любого другого.", + "show_shortcut_taken": "{keys} использует другое приложение. Закройте его или выключите и снова включите этот параметр.", + "default_app_label": "Почтовое приложение по умолчанию", + "default_app_description": "Открывать почтовые ссылки и файлы .eml, .ics и .vcf в этом приложении.", + "default_app_is_default": "Это приложение открывает ваши почтовые ссылки.", + "default_app_button": "Выбрать приложение по умолчанию…", + "new_mail": "Новая почта", + "no_subject": "(без темы)", + "action_delete": "Удалить", + "copy_as_file": "Копировать как файл", + "copied": "Файл скопирован. Его можно вставить в папку.", + "copy_failed": "Не удалось скопировать файл." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index a018ba55a..76252a888 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Najprv zadajte adresu servera.", "server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.", "server_invalid": "Táto adresa servera nie je platná." + }, + "desktopOs": { + "section_title": "Počítačová aplikácia", + "section_description": "Ako aplikácia spolupracuje s vaším počítačom.", + "close_to_tray_label": "Po zatvorení bežať ďalej v oblasti oznámení", + "close_to_tray_description": "Nová pošta a pripomienky prichádzajú aj po zatvorení okna.", + "start_at_login_label": "Spustiť po prihlásení do počítača", + "start_at_login_description": "Aplikácia sa po prihlásení spustí sama.", + "start_minimized_label": "Spustiť v oblasti oznámení", + "start_minimized_description": "Po prihlásení sa neotvorí žiadne okno. Otvorte ju ikonou v oblasti oznámení.", + "attention_label": "Pri novej pošte blikať na paneli úloh", + "attention_mac_label": "Pri novej pošte poskakovať ikonou v Docku", + "attention_description": "Len keď aplikácia nie je v popredí.", + "quiet_hours_label": "Čas pokoja", + "quiet_hours_description": "V tomto čase žiadne oznámenia ani zvuky. Pošta prichádza ďalej.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobraziť aplikáciu klávesovou skratkou", + "show_shortcut_description": "{keys} prenesie aplikáciu do popredia z akejkoľvek inej aplikácie.", + "show_shortcut_taken": "{keys} používa iná aplikácia. Zatvorte ju alebo túto možnosť vypnite a znova zapnite.", + "default_app_label": "Predvolená poštová aplikácia", + "default_app_description": "Otvárať poštové odkazy a súbory .eml, .ics a .vcf v tejto aplikácii.", + "default_app_is_default": "Táto aplikácia otvára vaše poštové odkazy.", + "default_app_button": "Vybrať predvolenú aplikáciu…", + "new_mail": "Nová pošta", + "no_subject": "(bez predmetu)", + "action_delete": "Odstrániť", + "copy_as_file": "Kopírovať ako súbor", + "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.", + "copy_failed": "Súbor sa nepodarilo skopírovať." } } diff --git a/locales/th/common.json b/locales/th/common.json index 065a7afbc..89c513d65 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6517,5 +6517,35 @@ "server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน", "server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป", "server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง" + }, + "desktopOs": { + "section_title": "แอปเดสก์ท็อป", + "section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ", + "close_to_tray_label": "ทำงานต่อในถาดระบบเมื่อปิด", + "close_to_tray_description": "เมลใหม่และการแจ้งเตือนยังเข้ามาหลังปิดหน้าต่าง", + "start_at_login_label": "เริ่มเมื่อลงชื่อเข้าใช้คอมพิวเตอร์", + "start_at_login_description": "แอปเริ่มเองเมื่อคุณลงชื่อเข้าใช้", + "start_minimized_label": "เริ่มในถาดระบบ", + "start_minimized_description": "ไม่มีหน้าต่างเปิดเมื่อลงชื่อเข้าใช้ เปิดด้วยไอคอนในถาดระบบ", + "attention_label": "กะพริบแถบงานเมื่อมีเมลใหม่", + "attention_mac_label": "ให้ไอคอน Dock เด้งเมื่อมีเมลใหม่", + "attention_description": "เฉพาะเมื่อแอปไม่ได้อยู่ด้านหน้า", + "quiet_hours_label": "ช่วงเวลาเงียบ", + "quiet_hours_description": "ไม่มีการแจ้งเตือนและเสียงในช่วงนี้ เมลยังเข้ามาตามปกติ", + "quiet_hours_from": "จาก", + "quiet_hours_to": "ถึง", + "show_shortcut_label": "แสดงแอปด้วยแป้นพิมพ์ลัด", + "show_shortcut_description": "{keys} นำแอปขึ้นมาด้านหน้าจากแอปอื่นใดก็ได้", + "show_shortcut_taken": "แอปอื่นใช้ {keys} อยู่ ปิดแอปนั้น หรือปิดแล้วเปิดตัวเลือกนี้อีกครั้ง", + "default_app_label": "แอปเมลเริ่มต้น", + "default_app_description": "เปิดลิงก์เมลและไฟล์ .eml, .ics และ .vcf ด้วยแอปนี้", + "default_app_is_default": "แอปนี้เปิดลิงก์เมลของคุณ", + "default_app_button": "เลือกแอปเริ่มต้น…", + "new_mail": "เมลใหม่", + "no_subject": "(ไม่มีหัวเรื่อง)", + "action_delete": "ลบ", + "copy_as_file": "คัดลอกเป็นไฟล์", + "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้", + "copy_failed": "คัดลอกไฟล์ไม่ได้" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 81acedbf7..be92c4d19 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Önce sunucu adresini girin.", "server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.", "server_invalid": "Bu sunucu adresi geçerli değil." + }, + "desktopOs": { + "section_title": "Masaüstü uygulaması", + "section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.", + "close_to_tray_label": "Kapatınca sistem tepsisinde çalışmaya devam et", + "close_to_tray_description": "Pencereyi kapattıktan sonra da yeni postalar ve anımsatıcılar gelmeye devam eder.", + "start_at_login_label": "Bilgisayarda oturum açınca başlat", + "start_at_login_description": "Oturum açtığınızda uygulama kendiliğinden başlar.", + "start_minimized_label": "Sistem tepsisinde başlat", + "start_minimized_description": "Oturum açarken pencere açılmaz. Sistem tepsisindeki simgeyle açın.", + "attention_label": "Yeni postada görev çubuğunu yanıp söndür", + "attention_mac_label": "Yeni postada Dock simgesini zıplat", + "attention_description": "Yalnızca uygulama önde değilken.", + "quiet_hours_label": "Sessiz saatler", + "quiet_hours_description": "Bu saatlerde bildirim ve ses yok. Postalar gelmeye devam eder.", + "quiet_hours_from": "Başlangıç", + "quiet_hours_to": "bitiş", + "show_shortcut_label": "Uygulamayı klavye kısayoluyla göster", + "show_shortcut_description": "{keys} uygulamayı başka herhangi bir uygulamadan öne getirir.", + "show_shortcut_taken": "{keys} başka bir uygulama tarafından kullanılıyor. O uygulamayı kapatın ya da bunu kapatıp yeniden açın.", + "default_app_label": "Varsayılan posta uygulaması", + "default_app_description": "Posta bağlantılarını ve .eml, .ics, .vcf dosyalarını bu uygulamayla aç.", + "default_app_is_default": "Posta bağlantılarınızı bu uygulama açıyor.", + "default_app_button": "Varsayılan uygulamayı seç…", + "new_mail": "Yeni posta", + "no_subject": "(konu yok)", + "action_delete": "Sil", + "copy_as_file": "Dosya olarak kopyala", + "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.", + "copy_failed": "Dosya kopyalanamadı." } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 42dbd1aba..f77d1099c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6509,5 +6509,35 @@ "server_needed": "Спочатку введіть адресу сервера.", "server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.", "server_invalid": "Ця адреса сервера недійсна." + }, + "desktopOs": { + "section_title": "Настільний застосунок", + "section_description": "Як застосунок працює з вашим комп’ютером.", + "close_to_tray_label": "Під час закриття працювати далі в треї", + "close_to_tray_description": "Нова пошта й нагадування надходять і після закриття вікна.", + "start_at_login_label": "Запускати під час входу в систему", + "start_at_login_description": "Застосунок запускається сам, коли ви входите.", + "start_minimized_label": "Запускати в треї", + "start_minimized_description": "Під час входу вікно не відкривається. Відкрийте його значком у треї.", + "attention_label": "Блимати на панелі завдань за нової пошти", + "attention_mac_label": "Підстрибувати значком у Dock за нової пошти", + "attention_description": "Лише коли застосунок не на передньому плані.", + "quiet_hours_label": "Тихі години", + "quiet_hours_description": "У цей час немає сповіщень і звуків. Пошта надходить далі.", + "quiet_hours_from": "З", + "quiet_hours_to": "до", + "show_shortcut_label": "Показувати застосунок сполученням клавіш", + "show_shortcut_description": "{keys} виводить застосунок на передній план із будь-якого іншого.", + "show_shortcut_taken": "{keys} використовує інший застосунок. Закрийте його або вимкніть і знову увімкніть цей параметр.", + "default_app_label": "Поштовий застосунок за замовчуванням", + "default_app_description": "Відкривати поштові посилання та файли .eml, .ics і .vcf у цьому застосунку.", + "default_app_is_default": "Цей застосунок відкриває ваші поштові посилання.", + "default_app_button": "Вибрати застосунок за замовчуванням…", + "new_mail": "Нова пошта", + "no_subject": "(без теми)", + "action_delete": "Видалити", + "copy_as_file": "Копіювати як файл", + "copied": "Файл скопійовано. Його можна вставити в папку.", + "copy_failed": "Не вдалося скопіювати файл." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 9cbcd8bd3..4692a979d 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6521,5 +6521,35 @@ "server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.", "server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.", "server_invalid": "Địa chỉ máy chủ này không hợp lệ." + }, + "desktopOs": { + "section_title": "Ứng dụng máy tính", + "section_description": "Cách ứng dụng hoạt động với máy tính của bạn.", + "close_to_tray_label": "Tiếp tục chạy ở khay hệ thống khi đóng", + "close_to_tray_description": "Thư mới và lời nhắc vẫn đến sau khi bạn đóng cửa sổ.", + "start_at_login_label": "Khởi động khi đăng nhập vào máy tính", + "start_at_login_description": "Ứng dụng tự khởi động khi bạn đăng nhập.", + "start_minimized_label": "Khởi động ở khay hệ thống", + "start_minimized_description": "Không có cửa sổ nào mở khi đăng nhập. Mở bằng biểu tượng ở khay hệ thống.", + "attention_label": "Nhấp nháy thanh tác vụ khi có thư mới", + "attention_mac_label": "Làm biểu tượng Dock nảy khi có thư mới", + "attention_description": "Chỉ khi ứng dụng không ở phía trước.", + "quiet_hours_label": "Giờ yên tĩnh", + "quiet_hours_description": "Không có thông báo hay âm thanh vào giờ này. Thư vẫn đến.", + "quiet_hours_from": "Từ", + "quiet_hours_to": "đến", + "show_shortcut_label": "Hiện ứng dụng bằng phím tắt", + "show_shortcut_description": "{keys} đưa ứng dụng lên trước từ bất kỳ ứng dụng nào khác.", + "show_shortcut_taken": "Ứng dụng khác đang dùng {keys}. Hãy đóng ứng dụng đó, hoặc tắt rồi bật lại tùy chọn này.", + "default_app_label": "Ứng dụng thư mặc định", + "default_app_description": "Mở liên kết thư và tệp .eml, .ics, .vcf bằng ứng dụng này.", + "default_app_is_default": "Ứng dụng này mở các liên kết thư của bạn.", + "default_app_button": "Chọn ứng dụng mặc định…", + "new_mail": "Thư mới", + "no_subject": "(không có chủ đề)", + "action_delete": "Xóa", + "copy_as_file": "Sao chép dưới dạng tệp", + "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.", + "copy_failed": "Không thể sao chép tệp." } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 5ba44316f..2e49af076 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6509,5 +6509,35 @@ "server_needed": "請先輸入伺服器位址。", "server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。", "server_invalid": "此伺服器位址無效。" + }, + "desktopOs": { + "section_title": "桌面應用程式", + "section_description": "應用程式如何與您的電腦配合。", + "close_to_tray_label": "關閉時繼續在系統匣中執行", + "close_to_tray_description": "關閉視窗後,新郵件和提醒仍會送達。", + "start_at_login_label": "登入電腦時啟動", + "start_at_login_description": "您登入時應用程式會自動啟動。", + "start_minimized_label": "在系統匣中啟動", + "start_minimized_description": "登入時不開啟視窗。請透過系統匣圖示開啟。", + "attention_label": "有新郵件時閃爍工作列", + "attention_mac_label": "有新郵件時讓 Dock 圖示跳動", + "attention_description": "僅在應用程式不在前景時。", + "quiet_hours_label": "勿擾時段", + "quiet_hours_description": "此時段沒有通知和聲音。郵件仍會送達。", + "quiet_hours_from": "從", + "quiet_hours_to": "到", + "show_shortcut_label": "使用鍵盤快速鍵顯示應用程式", + "show_shortcut_description": "{keys} 可從任何其他應用程式將此應用程式帶到前景。", + "show_shortcut_taken": "其他應用程式正在使用 {keys}。請關閉該應用程式,或關閉此選項後再開啟。", + "default_app_label": "預設郵件應用程式", + "default_app_description": "使用此應用程式開啟郵件連結以及 .eml、.ics 和 .vcf 檔案。", + "default_app_is_default": "此應用程式會開啟您的郵件連結。", + "default_app_button": "選擇預設應用程式…", + "new_mail": "新郵件", + "no_subject": "(無主旨)", + "action_delete": "刪除", + "copy_as_file": "複製為檔案", + "copied": "檔案已複製。您可以將其貼到資料夾中。", + "copy_failed": "無法複製檔案。" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index f53c82da4..746815777 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6509,5 +6509,35 @@ "server_needed": "请先输入服务器地址。", "server_declined": "你没有允许此服务器,因此没有向它发送任何内容。", "server_invalid": "此服务器地址无效。" + }, + "desktopOs": { + "section_title": "桌面应用", + "section_description": "应用如何与你的电脑配合。", + "close_to_tray_label": "关闭时继续在托盘中运行", + "close_to_tray_description": "关闭窗口后,新邮件和提醒仍会到达。", + "start_at_login_label": "登录电脑时启动", + "start_at_login_description": "你登录时应用会自动启动。", + "start_minimized_label": "在托盘中启动", + "start_minimized_description": "登录时不打开窗口。请通过托盘图标打开。", + "attention_label": "有新邮件时闪烁任务栏", + "attention_mac_label": "有新邮件时让 Dock 图标跳动", + "attention_description": "仅在应用不在前台时。", + "quiet_hours_label": "免打扰时段", + "quiet_hours_description": "此时段没有通知和声音。邮件仍会到达。", + "quiet_hours_from": "从", + "quiet_hours_to": "到", + "show_shortcut_label": "使用键盘快捷键显示应用", + "show_shortcut_description": "{keys} 可从任何其他应用把此应用带到前台。", + "show_shortcut_taken": "其他应用正在使用 {keys}。请关闭该应用,或关闭此选项后重新打开。", + "default_app_label": "默认邮件应用", + "default_app_description": "用此应用打开邮件链接以及 .eml、.ics 和 .vcf 文件。", + "default_app_is_default": "此应用会打开你的邮件链接。", + "default_app_button": "选择默认应用…", + "new_mail": "新邮件", + "no_subject": "(无主题)", + "action_delete": "删除", + "copy_as_file": "复制为文件", + "copied": "文件已复制。你可以将其粘贴到文件夹中。", + "copy_failed": "无法复制文件。" } } From b805571dec2cdb5cb633e0e9c81becc84826b99f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:30:37 +0100 Subject: [PATCH 02/45] fix: drag attachments and messages out of the desktop app with their bytes in hand --- components/email/email-viewer.tsx | 9 ++++ .../__tests__/use-email-drag-routing.test.tsx | 5 +-- hooks/use-attachment-drag.ts | 43 +++++++++++++------ hooks/use-email-drag.ts | 26 +++++++---- lib/desktop/__tests__/os-window.test.tsx | 30 ++++++++++--- lib/desktop/file-drag.ts | 26 ++++++++--- 6 files changed, 101 insertions(+), 38 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 09c925b5e..9dbde37b4 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -685,6 +685,15 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl const source = useMemo(() => ({ name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', + getBlob: async () => { + if (attachment.blobId && client) { + return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); + } + const bytes = attachment.tnefData ?? (attachment.decryptedAttachment ? getAttachmentContentBytes(attachment.decryptedAttachment) : null); + if (!bytes || bytes.byteLength === 0) return null; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + return new Blob([buffer], { type: inertBlobType(attachment.type) }); + }, getBlobUrl: async () => { if (attachment.blobId && client) { try { diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx index 242f40e5f..e3757d946 100644 --- a/hooks/__tests__/use-email-drag-routing.test.tsx +++ b/hooks/__tests__/use-email-drag-routing.test.tsx @@ -734,11 +734,10 @@ describe('message row drag hover wiring', () => { 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 { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); - await warm(result); const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true); window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never; - vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(await readBlob(objects.get(url)!)))); + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + await warm(result); try { startDrag.mockClear(); // Plain: still a move to a folder, still DownloadURL for Explorer. diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts index ce0047251..6136c4453 100644 --- a/hooks/use-attachment-drag.ts +++ b/hooks/use-attachment-drag.ts @@ -34,6 +34,8 @@ export interface AttachmentDragSource { name: string; type: string; getBlobUrl: () => Promise; + /** The bytes themselves, for the desktop app's native drag. */ + getBlob?: () => Promise; } export interface UseAttachmentDragResult { @@ -59,6 +61,8 @@ export function useAttachmentDrag( const urlRef = useRef(null); const ownedRef = useRef(false); const inFlightRef = useRef | null>(null); + const blobRef = useRef(null); + const nativeDrag = canDragNativeFiles() && !!source.getBlob; useEffect(() => { return () => { @@ -73,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() @@ -90,7 +107,7 @@ export function useAttachmentDrag( .finally(() => { inFlightRef.current = null; }); - }, [enabled, source]); + }, [enabled, source, nativeDrag]); const handleDragStart = useCallback( (e: DragEvent) => { @@ -98,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 @@ -107,13 +133,6 @@ export function useAttachmentDrag( return; } - // In the desktop app the OS's own drag, with the real file on disk first. - if (canDragNativeFiles()) { - e.preventDefault(); - void startNativeFileDrag([{ name, url }]); - return; - } - // `DownloadURL` format: ::. The filename must be // raw - URL-encoding it lands literally on disk (`%20` instead of a // space). Callers are expected to sanitise reserved chars (`:` etc.) @@ -121,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(() => { @@ -136,8 +155,8 @@ export function useAttachmentDrag( }, []); const copyAsFile = useCallback(async () => { - const url = urlRef.current ?? await source.getBlobUrl(); - return url ? copyFilesToClipboard([{ name: source.name || "download", url }]) : false; + const blob = blobRef.current ?? await source.getBlob?.(); + return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false; }, [source]); if (!enabled) return NOOP_HANDLERS; @@ -147,6 +166,6 @@ export function useAttachmentDrag( onPointerEnter: prefetch, onDragStart: handleDragStart, onDragEnd: handleDragEnd, - ...(canDragNativeFiles() ? { onCopyAsFile: copyAsFile } : {}), + ...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}), }; } diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index b60a4e910..6debff52c 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -9,7 +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, startNativeFileDrag } from "@/lib/desktop/file-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 { @@ -104,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 { @@ -127,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise { }), ); const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" }); - return URL.createObjectURL(zipBlob); + return keepDragBlob(URL.createObjectURL(zipBlob), zipBlob); } function prefetchEmailBundle( @@ -225,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; @@ -298,9 +304,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD 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. - if (e.altKey && canDragNativeFiles()) { + const blob = e.altKey ? keptDragBlob(url) : undefined; + if (blob) { e.preventDefault(); - void startNativeFileDrag([{ name, url }]); + void startNativeFileDrag([{ name, blob }]); return; } // `DownloadURL` format: ::. Chromium expects @@ -321,9 +328,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions)); const ready = getReadyBundle(routed, bundleOptions); if (ready) { - if (e.altKey && canDragNativeFiles()) { + const blob = e.altKey ? keptDragBlob(ready.url) : undefined; + if (blob) { e.preventDefault(); - void startNativeFileDrag([{ name: ready.name, url: ready.url }]); + void startNativeFileDrag([{ name: ready.name, blob }]); return; } e.dataTransfer.setData( diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index 301ee9abc..f7f3a5012 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -200,23 +200,23 @@ describe('files on the window', () => { describe('dragging an attachment out', () => { const blobUrl = 'blob:https://app/1'; - const source = { name: 'report.pdf', type: 'application/pdf', getBlobUrl: async () => blobUrl }; + 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 Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); return hook; } - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3])))); - }); - 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 }); @@ -231,6 +231,22 @@ describe('dragging an attachment out', () => { 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 a browser: still DownloadURL, and no copy button', async () => { const hook = await ready(); const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts index c3f6476a7..07084646c 100644 --- a/lib/desktop/file-drag.ts +++ b/lib/desktop/file-drag.ts @@ -6,11 +6,26 @@ 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; - /** A blob: URL the page already holds. */ - url?: string; - data?: Uint8Array; + blob: Blob; +} + +const keptBlobs = new Map(); + +/** In the app, remembers what's behind an object URL made for dragging. */ +export function keepDragBlob(url: string, blob: Blob): string { + if (canDragNativeFiles()) keptBlobs.set(url, blob); + return url; +} + +export function keptDragBlob(url: string): Blob | undefined { + return keptBlobs.get(url); +} + +export function forgetDragBlob(url: string): void { + keptBlobs.delete(url); } export function canDragNativeFiles(): boolean { @@ -18,10 +33,7 @@ export function canDragNativeFiles(): boolean { } async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> { - if (file.data) return { name: file.name, data: file.data }; - if (!file.url) throw new Error('A file needs its bytes or a URL'); - const response = await fetch(file.url); - return { name: file.name, data: new Uint8Array(await response.arrayBuffer()) }; + return { name: file.name, data: new Uint8Array(await file.blob.arrayBuffer()) }; } /** From 8e469571887c01d802760793f736d705ebb51eff Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:34:29 +0100 Subject: [PATCH 03/45] feat: notify from the local store's new mail and take it offline when the machine sleeps --- lib/desktop/__tests__/os-mail.test.ts | 3 +- lib/desktop/__tests__/os-store.test.ts | 76 ++++++++++++++++++++++++ lib/desktop/__tests__/os-window.test.tsx | 3 +- lib/desktop/mail-notifications.ts | 16 +---- lib/desktop/power.ts | 34 ++--------- lib/desktop/system-events.ts | 56 +++++++++++++++++ lib/offline/offline-mode.ts | 10 ++++ 7 files changed, 155 insertions(+), 43 deletions(-) create mode 100644 lib/desktop/__tests__/os-store.test.ts create mode 100644 lib/desktop/system-events.ts diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts index edfad4809..1338162d0 100644 --- a/lib/desktop/__tests__/os-mail.test.ts +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -311,7 +311,8 @@ describe('from arrival to toast and sound', () => { }); it("the store's created event reaches a running watch, and nothing else", async () => { - const { noteStoreCreated, startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + 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') }); diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts new file mode 100644 index 000000000..7339a0aa4 --- /dev/null +++ b/lib/desktop/__tests__/os-store.test.ts @@ -0,0 +1,76 @@ +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. */ + +const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null })); +const setOnline = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('@/lib/store/client/api', async (original) => ({ + ...(await original()), + configureStore: (hooks: StorePageHooks | null) => { + if (hooks) configured.hooks = hooks; + }, + getStore: () => ({ + supported: () => true, + setOnline, + ensureOpen: async () => {}, + refresh: () => {}, + sync: async () => {}, + stop: () => {}, + }), +})); + +describe('the store and the OS integration', () => { + const client = { isOfflineOnly: () => false, getAccountId: () => 'acc' }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { useAuthStore } = await import('@/stores/auth-store'); + useAuthStore.setState({ getClientForAccount: () => client as never } as never); + const { registerOfflineHooks } = await import('@/lib/offline/offline-mode'); + registerOfflineHooks(); + }); + + afterEach(async () => { + const { setStoreCreatedListener } = await import('../system-events'); + setStoreCreatedListener(null); + }); + + it("hands the store's new mail to the notification path, with the login's client", async () => { + const { setStoreCreatedListener } = await import('../system-events'); + const listener = vi.fn(); + setStoreCreatedListener(listener); + configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1', 'e2'] }); + expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']); + }); + + it('other collections are not mail', async () => { + const { setStoreCreatedListener } = await import('../system-events'); + const listener = vi.fn(); + setStoreCreatedListener(listener); + configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'CalendarEvent', ids: ['c1'] }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('with nothing listening, as in a browser, it does nothing', () => { + expect(() => configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1'] })).not.toThrow(); + }); + + it('a sleep takes the engine offline; waking brings it back and syncs through the events it already listens to', async () => { + const { startOfflineMode, offlineSupported } = await import('@/lib/offline/offline-mode'); + expect(offlineSupported()).toBe(true); + const { runSleepHandlers } = await import('../system-events'); + const stop = startOfflineMode(); + runSleepHandlers(); + expect(setOnline).toHaveBeenLastCalledWith(false); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { onPower: (listener: (event: string) => void) => { listener('resume'); return () => {}; } } } as never; + const { startPowerWatch } = await import('../power'); + const stopPower = startPowerWatch(); + expect(setOnline).toHaveBeenLastCalledWith(true); + stopPower(); + stop(); + window.bulwarkDesktop = undefined; + }); +}); diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index f7f3a5012..2e005476f 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -271,7 +271,8 @@ describe('waking up', () => { let fire: (event: DesktopOsPowerEvent) => void = () => {}; setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os }); const power = await import('../power'); - return { fire: (event: DesktopOsPowerEvent) => fire(event), power, stop: power.startPowerWatch() }; + 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 () => { diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts index 090c9ebdf..05b3303ba 100644 --- a/lib/desktop/mail-notifications.ts +++ b/lib/desktop/mail-notifications.ts @@ -14,6 +14,7 @@ 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'; @@ -130,17 +131,6 @@ export interface MailNotifications extends NewMailWatch { setLabels(labels: MailNotificationLabels): void; } -let current: MailNotifications | null = null; - -/** - * The local store's `created` event, from lib/offline/offline-mode.ts's onEvent, once - * an account lives in the store: `noteStoreCreated(client, event.jmap, event.ids)`. - * Does nothing where notifications don't run: in a browser, in a pop-out. - */ -export function noteStoreCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void { - current?.noteCreated(client, accountId, ids); -} - export function startMailNotifications(initial: MailNotificationLabels): MailNotifications { let labels = initial; let batch: NewMail[] = []; @@ -176,9 +166,9 @@ export function startMailNotifications(initial: MailNotificationLabels): MailNot watch.stop(); if (timer) clearTimeout(timer); timer = null; - if (current === handle) current = null; + setStoreCreatedListener(null); }, }; - current = handle; + setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids)); return handle; } diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts index 84d5ccd58..44d429dfc 100644 --- a/lib/desktop/power.ts +++ b/lib/desktop/power.ts @@ -4,46 +4,24 @@ * 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 registers with addWakeHandler and addSleepHandler (setOnline, sync). + * 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'; -type Handler = () => void; - -const wakeHandlers = new Set(); -const sleepHandlers = new Set(); /** A resume and an unlock usually come together. */ const WAKE_REPEAT_MS = 2000; -export function addWakeHandler(handler: Handler): () => void { - wakeHandlers.add(handler); - return () => wakeHandlers.delete(handler); -} - -export function addSleepHandler(handler: Handler): () => void { - sleepHandlers.add(handler); - return () => sleepHandlers.delete(handler); -} - -function runAll(handlers: Set): void { - for (const handler of [...handlers]) { - try { - handler(); - } catch (error) { - console.error('A wake or sleep handler failed:', error); - } - } -} - function wake(): void { document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('online')); const policy = usePolicyStore.getState(); if (policy.loaded && !policy.fetching) void policy.fetchPolicy(); - runAll(wakeHandlers); + runWakeHandlers(); } export function startPowerWatch(): () => void { @@ -58,7 +36,7 @@ export function startPowerWatch(): () => void { }; const stopPower = bridge.onPower((event) => { if (event === 'resume' || event === 'unlock-screen') wakeOnce(); - else if (event === 'suspend') runAll(sleepHandlers); + else if (event === 'suspend') runSleepHandlers(); }); // Wi-Fi to cable is a new network with no offline moment in between. const connection = (navigator as unknown as { connection?: EventTarget }).connection; diff --git a/lib/desktop/system-events.ts b/lib/desktop/system-events.ts new file mode 100644 index 000000000..f1b258077 --- /dev/null +++ b/lib/desktop/system-events.ts @@ -0,0 +1,56 @@ +/** + * What the local store and the OS integration say to each other, with nothing imported + * either way: lib/offline runs in every browser, and the notification code pulls in half + * the app's stores. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +type Handler = () => void; +type CreatedListener = (client: IJMAPClient, accountId: string, ids: readonly string[]) => void; + +const wakeHandlers = new Set(); +const sleepHandlers = new Set(); +let createdListener: CreatedListener | null = null; + +export function addWakeHandler(handler: Handler): () => void { + wakeHandlers.add(handler); + return () => { + wakeHandlers.delete(handler); + }; +} + +export function addSleepHandler(handler: Handler): () => void { + sleepHandlers.add(handler); + return () => { + sleepHandlers.delete(handler); + }; +} + +function runAll(handlers: Set): void { + for (const handler of [...handlers]) { + try { + handler(); + } catch (error) { + console.error('A wake or sleep handler failed:', error); + } + } +} + +export function runWakeHandlers(): void { + runAll(wakeHandlers); +} + +export function runSleepHandlers(): void { + runAll(sleepHandlers); +} + +/** The notification path, while it runs. */ +export function setStoreCreatedListener(listener: CreatedListener | null): void { + createdListener = listener; +} + +/** The store's `created` event for mail. Does nothing in a browser or a pop-out. */ +export function reportStoreCreatedMail(client: IJMAPClient, accountId: string, ids: readonly string[]): void { + createdListener?.(client, accountId, ids); +} diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 836e1286b..beff3d52e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -60,6 +60,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from ' import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; +import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events'; export type OfflineNotice = | { kind: 'queued'; subject: string } @@ -180,6 +181,10 @@ const storeHooks: StorePageHooks = { notify({ kind: 'store-reset' }); }, onEvent(event) { + if (event.type === 'created' && event.collection === 'Email') { + const client = onlineClientFor(event.account); + if (client) reportStoreCreatedMail(client, event.jmap, event.ids); + } if (event.type === 'synced') { useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } })); forgetMailSnapshot(event.account); @@ -906,6 +911,11 @@ export function startOfflineMode(): () => void { window.removeEventListener('offline', onOffline); }); + // The desktop app going to sleep. Waking comes with the two events above and below. + cleanups.push(addSleepHandler(() => { + void getStore().setOnline(false).catch(() => {}); + })); + // Back in front: catch up now rather than at the next interval. const onVisible = () => { if (document.visibilityState !== 'visible') return; From b2931ced3ed784204d9d6d15196111a5eea23e7c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:44:02 +0100 Subject: [PATCH 04/45] feat: bring desktop windows back at the next start, open pop-outs from a warm spare and ask natively before discarding a draft --- app/(main)/[locale]/popout/spare/page.tsx | 7 ++ .../__tests__/composer-close-guard.test.tsx | 35 +++++- components/email/email-composer.tsx | 41 ++++++- .../__tests__/popout-compose-app.test.tsx | 45 ++++++++ components/popout/popout-compose-app.tsx | 64 ++++++++++- components/popout/popout-spare-app.tsx | 45 ++++++++ .../settings/desktop-window-settings.tsx | 26 +++++ components/settings/general-settings.tsx | 2 + lib/desktop/preferences.ts | 71 +++++++++++++ lib/desktop/types.ts | 100 +++++++++++++++++- lib/desktop/window-restore.ts | 40 +++++++ lib/draft-composer-data.ts | 60 +++++++++++ lib/popout/open.ts | 6 ++ lib/popout/window-role.ts | 10 +- locales/ar/common.json | 15 +++ locales/ca/common.json | 15 +++ locales/cs/common.json | 15 +++ locales/da/common.json | 15 +++ locales/de/common.json | 15 +++ locales/en/common.json | 15 +++ locales/es/common.json | 15 +++ locales/et/common.json | 15 +++ locales/fa/common.json | 15 +++ locales/fr/common.json | 15 +++ locales/he/common.json | 15 +++ locales/hu/common.json | 15 +++ locales/it/common.json | 15 +++ locales/ja/common.json | 15 +++ locales/ko/common.json | 15 +++ locales/lv/common.json | 15 +++ locales/mn/common.json | 15 +++ locales/nb/common.json | 15 +++ locales/nl/common.json | 15 +++ locales/pl/common.json | 15 +++ locales/pt/common.json | 15 +++ locales/ro/common.json | 15 +++ locales/ru/common.json | 15 +++ locales/sk/common.json | 15 +++ locales/th/common.json | 15 +++ locales/tr/common.json | 15 +++ locales/uk/common.json | 15 +++ locales/vi/common.json | 15 +++ locales/zh-TW/common.json | 15 +++ locales/zh/common.json | 15 +++ 44 files changed, 987 insertions(+), 15 deletions(-) create mode 100644 app/(main)/[locale]/popout/spare/page.tsx create mode 100644 components/popout/popout-spare-app.tsx create mode 100644 components/settings/desktop-window-settings.tsx create mode 100644 lib/desktop/preferences.ts create mode 100644 lib/desktop/window-restore.ts create mode 100644 lib/draft-composer-data.ts diff --git a/app/(main)/[locale]/popout/spare/page.tsx b/app/(main)/[locale]/popout/spare/page.tsx new file mode 100644 index 000000000..1eafe6639 --- /dev/null +++ b/app/(main)/[locale]/popout/spare/page.tsx @@ -0,0 +1,7 @@ +import { PopoutSpareApp } from "@/components/popout/popout-spare-app"; + +// The desktop app's warm spare (components/popout/popout-spare-app.tsx). Not in +// Lite: scripts/lite removes this tree. +export default function PopoutSparePage() { + return ; +} diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 8ac5e5b82..9ef9370a7 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; @@ -235,6 +235,39 @@ describe('composer close guard with a follow-up', () => { expect(afterClose).not.toHaveBeenCalled(); }); + describe('in the desktop app', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const nativeBridge = (answer: number) => { + const confirm = vi.fn(async () => answer); + window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm } } as never; + return confirm; + }; + + it('asks through the OS, and shows no dialog of its own', async () => { + const confirm = nativeBridge(2); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]); + expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); + // The last button is Cancel: nothing happens. + await act(async () => {}); + expect(onClose).not.toHaveBeenCalled(); + expect(afterClose).not.toHaveBeenCalled(); + }); + + it('Discard discards, and the follow-up runs', async () => { + nativeBridge(1); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + }); + }); + it('runs the follow-up after discarding', async () => { const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d4ff3a9a8..2e25fdb4d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -166,6 +166,8 @@ import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; +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"; @@ -1936,6 +1938,14 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // A composer that comes back with the app: a draft it opened on counts from the start. + // Gone with the composer: sent, discarded or closed. + useEffect(() => { + if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current }); + return () => noteOpenDraft(effectiveSessionKey, null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveSessionKey]); + // Auto-save draft to server on page close (best-effort) const ownWindowRef = useRef(ownWindow); ownWindowRef.current = ownWindow; @@ -3005,6 +3015,8 @@ export function EmailComposer({ draftIdRef.current = savedDraftId; draftAccountIdRef.current = draftAccountId; draftLocalAccountIdRef.current = savingLogin; + // Before a save's promise settles: quitting waits for it, then keeps this list. + noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin, ownerAccountId: draftAccountId }); setDraftId(savedDraftId); setDraftAccountId(draftAccountId); setDraftLocalAccountId(savingLogin); @@ -4418,6 +4430,29 @@ export function EmailComposer({ emitClose(); }; + /** The question before closing or discarding: the OS's own in the desktop app. */ + const openCloseDialog = (kind: 'discard' | 'close') => { + const dialogs = getDesktopBridge()?.dialogs; + if (!dialogs) { + setCloseDialogKind(kind); + setShowCloseDialog(true); + return; + } + const discarding = kind === 'discard'; + const buttons = discarding ? [t('discard'), t('cancel')] : [tCommon('save'), t('discard'), t('cancel')]; + void dialogs.confirm({ + type: 'warning', + message: discarding ? t('discard_draft_title') : t('close_draft_title'), + detail: discarding ? t('discard_draft_message') : t('close_draft_message'), + buttons, + defaultId: discarding ? 1 : 0, + }).then((choice) => { + if (buttons[choice] === t('discard')) handleDiscardAndClose(); + else if (!discarding && choice === 0) void handleSaveDraftAndClose(); + else afterCloseRef.current = null; + }); + }; + // Discard deletes the draft, also one that has no unsaved changes, such as // a scheduled message reopened with "Cancel and edit". It used to share the // X button's path, which closed such a composer and kept the draft. @@ -4429,8 +4464,7 @@ export function EmailComposer({ return; } afterCloseRef.current = null; - setCloseDialogKind('discard'); - setShowCloseDialog(true); + openCloseDialog('discard'); }; const handleClose = (afterClose?: () => void) => { @@ -4442,8 +4476,7 @@ export function EmailComposer({ && !saveFailedRef.current && !attachmentsRef.current.some(att => att.uploading || att.error); if (isDirtyRef.current && !savedAlready) { - setCloseDialogKind('close'); - setShowCloseDialog(true); + openCloseDialog('close'); } else { cleanClose(); } diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 3ff5cf883..2a02faf0f 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -67,6 +67,51 @@ function openAt(handoffId: string) { render(); } +describe('a composer coming back on its draft', () => { + const closeSelf = vi.fn(); + + beforeEach(() => { + closeSelf.mockClear(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never; + }); + + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const draft = { + id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ email: 'me@example.org' }], + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }], + }; + + const openDraft = (getEmail: ReturnType, search = 'draft=d1&account=alice&owner=group') => { + useAuthStore.setState({ client: { getEmail } as never, getClientForAccount: () => undefined } as never); + window.history.replaceState(null, '', `/en/popout/compose?${search}`); + render(); + }; + + it('reads the draft from where it lives, and opens the composer on it, id and all', async () => { + const getEmail = vi.fn(async () => draft); + openDraft(getEmail); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written')); + expect(getEmail).toHaveBeenCalledWith('d1', 'group'); + // Saving goes over this draft, not next to it, and its files stay. + expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: 'alice', to: 'a@example.org' }); + expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1); + }); + + it('a draft that has gone since (sent elsewhere) closes the window', async () => { + openDraft(vi.fn(async () => null)); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId('composer')).toBeNull(); + }); + + it('a server that doesn\'t answer closes it too: the draft is safe on the server', async () => { + openDraft(vi.fn(async () => { throw new Error('offline'); })); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + }); +}); + describe('PopoutComposeApp', () => { it('tells the opening window to let go only with the composer on screen', async () => { const handoff = createComposeHandoff()!; diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 6f0bebeff..7aaa4f709 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -18,6 +18,8 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { closePopoutWindow } from "@/lib/popout/window-role"; +import { composerDataFromDraft } from "@/lib/draft-composer-data"; +import { useIdentityStore } from "@/stores/identity-store"; import { PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ @@ -31,10 +33,60 @@ const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" }; /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; -function readLaunch(): { handoffId: string | null; accountId?: string } { +function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } { if (typeof window === "undefined") return { handoffId: null }; const search = new URLSearchParams(window.location.search); - return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined }; + return { + handoffId: search.get("handoff"), + accountId: search.get("account") ?? undefined, + draftId: search.get("draft") ?? undefined, + ownerAccountId: search.get("owner") ?? undefined, + }; +} + +/** + * The composer that was open when the desktop app quit, back on its draft. A draft + * that has gone since (sent or deleted elsewhere) means there's nothing to show. + */ +function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { + draftId: string; + accountId?: string; + ownerAccountId?: string; + onReady: (payload: ComposePopoutPayload) => void; +}) { + const t = useTranslations(); + const tWindows = useTranslations("pwaWindows"); + const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined) ?? s.client); + const identities = useIdentityStore((s) => s.identities); + useEffect(() => { + if (!client) return; + let cancelled = false; + void client.getEmail(draftId, ownerAccountId).then((draft) => { + if (cancelled) return; + if (!draft) { + closePopoutWindow(); + return; + } + onReady({ + mode: "compose", + initialData: composerDataFromDraft(draft, { + accountId: ownerAccountId, + localAccountId: accountId, + identities, + noSubject: t("email_composer.no_subject"), + }), + }); + }).catch((error) => { + debug.error("Failed to reopen a draft:", error); + if (!cancelled) closePopoutWindow(); + }); + return () => { + cancelled = true; + }; + // Once per client: the draft doesn't change under a window still opening. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client, draftId, ownerAccountId]); + return ; } /** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ @@ -181,8 +233,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; /** `/popout/compose`: a draft handed over by another window, or a new message. */ export function PopoutComposeApp() { const t = useTranslations("pwaWindows"); - const [{ handoffId, accountId }] = useState(readLaunch); - const [payload, setPayload] = useState(handoffId ? null : EMPTY_PAYLOAD); + const [{ handoffId, accountId, draftId, ownerAccountId }] = useState(readLaunch); + const [payload, setPayload] = useState(handoffId || draftId ? null : EMPTY_PAYLOAD); useEffect(() => { if (!handoffId) return; @@ -216,7 +268,9 @@ export function PopoutComposeApp() { return ( - {payload ? : } + {payload ? + : draftId ? + : } ); } diff --git a/components/popout/popout-spare-app.tsx b/components/popout/popout-spare-app.tsx new file mode 100644 index 000000000..26f67104e --- /dev/null +++ b/components/popout/popout-spare-app.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { withBasePath } from "@/lib/browser-navigation"; +import { PopoutComposeApp } from "./popout-compose-app"; +import { PopoutMessageApp } from "./popout-message-app"; + +/** The message id a pop-out address names. */ +function messageIdOf(pathname: string): string { + const segments = pathname.split("/").filter(Boolean); + const at = segments.lastIndexOf("message"); + return at >= 0 && segments[at + 1] ? decodeURIComponent(segments[at + 1]) : ""; +} + +/** + * The desktop app's warm spare: a hidden window with the pop-out code loaded and + * nothing signed in. When a pop-out is wanted the shell gives it an address. The + * address bar changes without a load, which is where the pop-outs read their + * arguments from, and the right one renders in a window that's already up. + */ +export function PopoutSpareApp() { + const [target, setTarget] = useState(null); + + useEffect(() => { + const spare = getDesktopBridge()?.windows?.spare; + if (!spare) { + // Someone typed the address, or a browser followed a link. + window.location.replace(withBasePath("/")); + return; + } + const stop = spare.onActivate((url) => { + const next = new URL(url, window.location.href); + window.history.replaceState(null, "", `${next.pathname}${next.search}`); + setTarget(next); + spare.taken(); + }); + spare.ready(); + return stop; + }, []); + + if (!target) return
; + if (/\/popout\/compose\/?$/.test(target.pathname)) return ; + return ; +} diff --git a/components/settings/desktop-window-settings.tsx b/components/settings/desktop-window-settings.tsx new file mode 100644 index 000000000..3d604728c --- /dev/null +++ b/components/settings/desktop-window-settings.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { hasDesktopWindows } from "@/lib/popout/support"; +import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +/** How the desktop app's windows behave. Only there: a browser has nothing to set. */ +export function DesktopWindowSettings() { + const t = useTranslations("desktopUi.settings"); + const prefs = useDesktopPrefs(); + const [shown, setShown] = useState(false); + useEffect(() => setShown(hasDesktopWindows()), []); + if (!shown) return null; + return ( + + + setDesktopPref("openOnDoubleClick", checked)} /> + + + setDesktopPref("restoreWindows", checked)} /> + + + ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 41d2ba072..7dc969209 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from '@/components/icons'; import { IS_LITE } from '@/lib/lite'; +import { DesktopWindowSettings } from './desktop-window-settings'; /** * The General page: language and time, which links the browser hands to @@ -26,6 +27,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea {!IS_LITE && } + )}
+ {desktopMenu && menuTarget && ( + + {!locked(menuTarget) && ( + { menu.closeContextMenu(); onOpen(menuTarget); }} /> + )} + {!downloadsOff && ( + { menu.closeContextMenu(); onDownload(menuTarget); }} /> + )} + {!downloadsOff && onCopyFile && ( + { menu.closeContextMenu(); onCopyFile(menuTarget); }} /> + )} + + )} ); } diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx index 7f4e306fa..df2f7d335 100644 --- a/components/layout/window-title-bar.tsx +++ b/components/layout/window-title-bar.tsx @@ -1,6 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopMenuBarEntry } from "@/lib/desktop/types"; import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay"; /** @@ -12,10 +14,46 @@ import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/ * Renders nothing, and changes nothing on the page, without an overlay. It * repeats the title for sighted users only, so it stays out of the * accessibility tree and out of the tab order. + * + * In the desktop app on Windows, Alt swaps the title for the menu's names, since a + * window without a native frame has no native bar. Each name opens the app's menu + * as a native one (src/main/menu.ts in the desktop repo). */ export function WindowTitleBar() { const [area, setArea] = useState(null); const [title, setTitle] = useState(""); + const [entries, setEntries] = useState(null); + const stripRef = useRef(null); + const previousFocus = useRef(null); + + const openMenu = useCallback((id: string) => { + const button = stripRef.current?.querySelector(`[data-menu-id="${CSS.escape(id)}"]`); + if (!button) return; + const rect = button.getBoundingClientRect(); + getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom }); + }, []); + + useEffect(() => { + const bar = getDesktopBridge()?.menu?.bar; + if (!bar) return; + return bar.onEvent((event) => { + if (!event.visible) { + setEntries(null); + previousFocus.current?.focus?.(); + previousFocus.current = null; + return; + } + void bar.entries().then((list) => { + previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null; + setEntries(list); + // Once the names are on screen. + window.setTimeout(() => { + if (event.open) openMenu(event.open); + else stripRef.current?.querySelector("button")?.focus(); + }, 0); + }); + }); + }, [openMenu]); useEffect(() => watchTitlebarArea((next) => { applyTitlebarInset(next); @@ -35,6 +73,52 @@ export function WindowTitleBar() { if (!area) return null; + const onStripKey = (event: React.KeyboardEvent) => { + const buttons = Array.from(stripRef.current?.querySelectorAll("button") ?? []); + const at = buttons.indexOf(document.activeElement as HTMLElement); + if (event.key === "ArrowRight" || event.key === "ArrowLeft") { + event.preventDefault(); + const delta = event.key === "ArrowRight" ? 1 : -1; + buttons[(at + delta + buttons.length) % buttons.length]?.focus(); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + buttons[at]?.click(); + } else if (event.key === "Escape") { + event.preventDefault(); + setEntries(null); + previousFocus.current?.focus?.(); + previousFocus.current = null; + } + }; + + if (entries) { + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); + } + return ( - , + + , document.body ); } @@ -578,11 +618,11 @@ export function ContextMenuItem({ {Icon && } {hint ? ( - {label} + {label} {hint} ) : ( - {label} + {label} )} {/* Key hints mean nothing without a keyboard: hidden on touch (R8). */} {shortcut && ( @@ -625,6 +665,7 @@ export function ContextMenuSubMenu({ hint, }: ContextMenuSubMenuProps) { const parent = useContext(MenuLevelContext); + const native = useContext(NativeMenuContext); const id = useId(); // Outside a ContextMenu (a host's own panel) it keeps its own state. const [localOpen, setLocalOpen] = useState(false); @@ -704,16 +745,21 @@ export function ContextMenuSubMenu({ {Icon && } {hint ? ( - {label} + {label} {hint} ) : ( - {label} + {label} )}
- {isOpen && !disabled && ( + {native && !disabled && ( + + )} + {!native && isOpen && !disabled && ( +
{children}
); diff --git a/hooks/use-context-menu.ts b/hooks/use-context-menu.ts index dfdc02348..eb412b42d 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import type { ContextMenuPosition } from "@/components/ui/context-menu"; +import { nativeMenuIsOpen } from "@/lib/desktop/native-menu"; interface ContextMenuState { isOpen: boolean; @@ -134,12 +135,14 @@ export function useContextMenu(): UseContextMenuReturn { }; const handleClickOutside = (e: MouseEvent) => { + if (nativeMenuIsOpen()) return; if (menuRef.current && !menuRef.current.contains(e.target as Node)) { closeContextMenu(); } }; const handleScroll = (e: Event) => { + if (nativeMenuIsOpen()) return; if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) { return; } @@ -150,6 +153,8 @@ export function useContextMenu(): UseContextMenuReturn { // A focused control in the menu may have opened a system dialog that // took the focus from the window, such as a color input's (#1366). if (menuRef.current?.contains(document.activeElement)) return; + // In the desktop app the menu is native: it closes itself, and says what was picked. + if (nativeMenuIsOpen()) return; closeContextMenu(); }; diff --git a/lib/desktop/__tests__/native-ui.test.tsx b/lib/desktop/__tests__/native-ui.test.tsx new file mode 100644 index 000000000..2bab20eb1 --- /dev/null +++ b/lib/desktop/__tests__/native-ui.test.tsx @@ -0,0 +1,301 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContextMenu, ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from '@/components/ui/context-menu'; +import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '../types'; +import { accentColours, applySystemAccent } from '../system-accent'; +import { mergedDesktopState, registerDesktopSurface, resetDesktopCommands, runDesktopCommand, setDesktopBaseState } from '../commands'; +import { readMenu } from '../native-menu'; +import { noteOpenDraft, resetOpenDrafts, restoreUrlFor } from '../window-restore'; +import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '../preferences'; +import { closePopoutWindow, isPopoutPath } from '@/lib/popout/window-role'; + +function setBridge(bridge: Partial | undefined) { + window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; +} + +afterEach(() => { + setBridge(undefined); + resetDesktopCommands(); + resetOpenDrafts(); + localStorage.clear(); + resetDesktopPrefsCache(); + vi.useRealTimers(); +}); + +function testMenu(extra?: React.ReactNode, handlers: { reply?: () => void; inbox?: () => void } = {}) { + return ( + <> + Three messages + {})} shortcut="R" hint="to the sender" /> + {}} disabled /> + + + {})} /> + {}} /> + + + {}} /> + + {extra} + + ); +} + +describe('an HTML menu shown natively', () => { + let popup: ReturnType; + let answer: (id: string | null) => void; + + beforeEach(() => { + popup = vi.fn(() => new Promise((resolve) => { answer = resolve; })); + setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } }); + }); + + async function openMenu(children: React.ReactNode, onClose = vi.fn()) { + render({children}); + await waitFor(() => expect(popup).toHaveBeenCalled()); + return { items: popup.mock.calls[0][0] as DesktopNativeMenuItem[], at: popup.mock.calls[0][1], onClose }; + } + + it('reads every kind of row, in order, with submenus', async () => { + const { items } = await openMenu(testMenu()); + expect(items.map((item) => item.label ?? item.type)).toEqual(['Three messages', 'Reply', 'Forward', 'separator', 'Move to', 'Rules']); + expect(items[0].enabled).toBe(false); + expect(items[2].enabled).toBe(false); + expect(items[4].submenu?.map((item) => item.label)).toEqual(['Inbox', 'R&D']); + // A disabled submenu shows, and has nothing in it. + expect(items[5].enabled).toBe(false); + expect(items[5].submenu).toBeUndefined(); + }); + + it('takes the label without the shortcut or the hint', async () => { + const { items } = await openMenu(testMenu()); + expect(items[1].label).toBe('Reply'); + }); + + it('at the pointer from a right-click, under the button from a button', async () => { + const { at } = await openMenu(testMenu()); + expect(at).toBeUndefined(); + popup.mockClear(); + render( + {}}> + {}} /> + , + ); + await waitFor(() => expect(popup).toHaveBeenCalled()); + expect(popup.mock.calls[0][1]).toEqual({ x: 40, y: 74 }); + }); + + it('a pick runs the HTML row, the one a browser would run, and closes the menu', async () => { + const reply = vi.fn(); + const inbox = vi.fn(); + const { items, onClose } = await openMenu(testMenu(undefined, { reply, inbox })); + await act(async () => { + answer(items[4].submenu![0].id!); + }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(inbox).toHaveBeenCalledTimes(1); + expect(reply).not.toHaveBeenCalled(); + }); + + it('dismissing it runs nothing and closes the menu', async () => { + const reply = vi.fn(); + const { onClose } = await openMenu(testMenu(undefined, { reply })); + await act(async () => { + answer(null); + }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(reply).not.toHaveBeenCalled(); + }); + + it('the hidden copy is never visible', async () => { + await openMenu(testMenu()); + const menu = document.body.querySelector('[role="menu"]'); + expect(menu?.style.visibility).toBe('hidden'); + }); + + it('stays HTML when something in it can\'t be shown natively', async () => { + render( + {}}> + {testMenu()} + , + ); + await waitFor(() => expect(document.body.querySelector('[role="menu"]')?.style.visibility).toBe('visible')); + expect(popup).not.toHaveBeenCalled(); + expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy(); + }); + + it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + expect(readMenu(root).unsupported).toBe(true); + }); + + it('a search field over a list is left out, and what it searches stays', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + const read = readMenu(root); + expect(read.unsupported).toBe(false); + expect(read.items.map((item) => item.label)).toEqual(['Inbox']); + }); + + it('reads check and radio rows', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + expect(readMenu(root).items).toMatchObject([ + { type: 'checkbox', checked: true, label: 'Show' }, + { type: 'radio', checked: false, label: 'Other' }, + ]); + }); +}); + +describe('the same menu in a browser', () => { + it('is the HTML menu, on screen, and no bridge is asked', async () => { + render( {}}>{testMenu()}); + await waitFor(() => expect(document.body.querySelector('[role="menu"]')?.style.visibility).toBe('visible')); + // A closed submenu's rows aren't in the page. + expect(screen.queryByRole('menuitem', { name: 'Inbox' })).toBeNull(); + }); +}); + +describe('menu commands', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('the newest surface answers, and an older one is back when it goes', () => { + const older = vi.fn(); + const newer = vi.fn(); + registerDesktopSurface({ handlers: { 'message.reply': older }, state: {} }); + const stop = registerDesktopSurface({ handlers: { 'message.reply': newer }, state: {} }); + expect(runDesktopCommand('message.reply')).toBe(true); + expect(newer).toHaveBeenCalledTimes(1); + expect(older).not.toHaveBeenCalled(); + stop(); + runDesktopCommand('message.reply'); + expect(older).toHaveBeenCalledTimes(1); + }); + + it('says when nothing answered, so the host can take it', () => { + expect(runDesktopCommand('go.calendar')).toBe(false); + }); + + it('the shell gets the state, surfaces over the base', () => { + const setState = vi.fn(); + setBridge({ version: 1, platform: 'win32', menu: { setState } }); + setDesktopBaseState({ 'message.reply': false, 'file.print': true }); + registerDesktopSurface({ handlers: {}, state: { 'message.reply': true } }); + vi.advanceTimersByTime(50); + expect(setState).toHaveBeenLastCalledWith({ 'message.reply': true, 'file.print': true }); + expect(mergedDesktopState()['file.print']).toBe(true); + }); +}); + +describe('composers that come back', () => { + it('tell the shell which draft they hold, and forget it when they go', () => { + const setRestore = vi.fn(); + setBridge({ version: 1, platform: 'win32', windows: { setRestore } }); + noteOpenDraft('a', { draftId: 'd1', localAccountId: 'login', ownerAccountId: 'group' }); + expect(setRestore).toHaveBeenLastCalledWith([expect.stringContaining('draft=d1')]); + expect(setRestore.mock.lastCall![0][0]).toContain('account=login'); + expect(setRestore.mock.lastCall![0][0]).toContain('owner=group'); + noteOpenDraft('a', { draftId: 'd2' }); + expect(setRestore.mock.lastCall![0]).toHaveLength(1); + expect(setRestore.mock.lastCall![0][0]).toContain('draft=d2'); + noteOpenDraft('a', null); + expect(setRestore).toHaveBeenLastCalledWith([]); + }); + + it('are pop-out addresses the window type check recognises', () => { + expect(restoreUrlFor({ draftId: 'x' })).toMatch(/\/popout\/compose\?draft=x$/); + }); + + it('do nothing in a browser', () => { + expect(() => noteOpenDraft('a', { draftId: 'd1' })).not.toThrow(); + }); +}); + +describe('a pop-out closing itself', () => { + it('goes through the shell in the desktop app, not window.close', () => { + const closeSelf = vi.fn(); + const close = vi.spyOn(window, 'close').mockImplementation(() => {}); + setBridge({ version: 1, platform: 'win32', windows: { closeSelf } }); + closePopoutWindow(); + expect(closeSelf).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + close.mockRestore(); + }); + + it('uses window.close in a browser', () => { + vi.useFakeTimers(); + const close = vi.spyOn(window, 'close').mockImplementation(() => {}); + closePopoutWindow(); + expect(close).toHaveBeenCalledTimes(1); + close.mockRestore(); + }); + + it('counts the warm spare as a pop-out, which is a guest of the main window', () => { + expect(isPopoutPath('/popout/spare')).toBe(true); + expect(isPopoutPath('/de/popout/spare')).toBe(true); + expect(isPopoutPath('/popout/spare/x')).toBe(false); + }); +}); + +describe('window settings', () => { + it('both default to on, and stay on this computer', () => { + expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true }); + setDesktopPref('restoreWindows', false); + resetDesktopPrefsCache(); + expect(readDesktopPrefs().restoreWindows).toBe(false); + expect(readDesktopPrefs().openOnDoubleClick).toBe(true); + }); + + it('a damaged value is the default', () => { + localStorage.setItem('bulwark-desktop-window-prefs', '{nope'); + expect(readDesktopPrefs().restoreWindows).toBe(true); + }); +}); + +describe('the OS accent colour', () => { + beforeEach(() => setBridge({ version: 1, platform: 'win32' })); + + const apply = (input: Partial[0]>) => { + const root = document.createElement('div'); + applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true, ...input }, root); + return root.style.getPropertyValue('--color-primary'); + }; + + it('is used while the theme follows the system', () => { + expect(apply({})).toBe('#0078d4'); + }); + + it('not under a theme the user picked', () => { + expect(apply({ followsSystem: false })).toBe(''); + }); + + it('not in a contrast theme', () => { + expect(apply({ highContrast: true })).toBe(''); + }); + + it('not where it would vanish against the page', () => { + expect(accentColours('#fefefe', false)).toBeNull(); + expect(accentColours('#050505', true)).toBeNull(); + }); + + it('text on it is what reads', () => { + expect(accentColours('#0078d4', false)?.foreground).toBe('#ffffff'); + expect(accentColours('#ffd400', true)?.foreground).toBe('#000000'); + }); + + it('a removed accent takes its colours off again', () => { + const root = document.createElement('div'); + applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true }, root); + applySystemAccent({ dark: false, followsSystem: true }, root); + expect(root.style.getPropertyValue('--color-primary')).toBe(''); + expect(root.style.getPropertyValue('--color-primary-foreground')).toBe(''); + }); + + it('does nothing in a browser', () => { + setBridge(undefined); + expect(apply({})).toBe(''); + }); +}); diff --git a/lib/desktop/commands.ts b/lib/desktop/commands.ts new file mode 100644 index 000000000..5013eeb66 --- /dev/null +++ b/lib/desktop/commands.ts @@ -0,0 +1,98 @@ +/** + * The desktop app's menu bar, on the webmail's side. The shell shows the menu and + * sends an id; what runs is the same handler the shortcut or button runs, from + * whichever part of the app registered it. What's on screen decides what's enabled: + * a surface reports that here, and the newest surface wins per command. + * + * Nothing happens in a browser: there's no bridge to publish to, and nobody sends + * an id. + */ + +import { useEffect, useRef } from 'react'; +import { getDesktopBridge } from './bridge'; +import type { DesktopMenuState } from './types'; + +export type DesktopCommandHandlers = Record void) | undefined>; + +interface Surface { + handlers: DesktopCommandHandlers; + state: DesktopMenuState; +} + +const surfaces = new Set(); +let base: DesktopMenuState = {}; +let timer: ReturnType | null = null; + +/** What the shell should show now: the base, with each surface over it, newest last. */ +export function mergedDesktopState(): DesktopMenuState { + const merged: DesktopMenuState = { ...base }; + for (const surface of surfaces) Object.assign(merged, surface.state); + return merged; +} + +function publish(): void { + if (timer) return; + timer = setTimeout(() => { + timer = null; + getDesktopBridge()?.menu?.setState(mergedDesktopState()); + }, 30); +} + +/** What holds whatever is on screen: signed in or not, the device policy. */ +export function setDesktopBaseState(state: DesktopMenuState): void { + base = state; + publish(); +} + +/** Runs a command from the newest surface that has it. False when none does. */ +export function runDesktopCommand(id: string): boolean { + for (const surface of [...surfaces].reverse()) { + const handler = surface.handlers[id]; + if (handler) { + handler(); + return true; + } + } + return false; +} + +export function registerDesktopSurface(surface: Surface): () => void { + surfaces.add(surface); + publish(); + return () => { + surfaces.delete(surface); + publish(); + }; +} + +function sameState(a: DesktopMenuState, b: DesktopMenuState): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** + * This part of the app answers these commands while `enabled`. Handlers can be new + * on every render: the newest ones run, and only a changed state goes to the shell. + */ +export function useDesktopCommands(handlers: DesktopCommandHandlers, state: DesktopMenuState, enabled = true): void { + const surfaceRef = useRef({ handlers, state }); + surfaceRef.current.handlers = handlers; + + useEffect(() => { + if (!enabled || !getDesktopBridge()?.menu) return; + return registerDesktopSurface(surfaceRef.current); + }, [enabled]); + + useEffect(() => { + if (sameState(surfaceRef.current.state, state)) return; + surfaceRef.current.state = state; + publish(); + }); +} + +/** Test helper. */ +export function resetDesktopCommands(): void { + surfaces.clear(); + base = {}; + if (timer) clearTimeout(timer); + timer = null; +} diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts new file mode 100644 index 000000000..ae8eca8a5 --- /dev/null +++ b/lib/desktop/native-menu.ts @@ -0,0 +1,146 @@ +/** + * Turns an HTML menu into a native one. The ContextMenu component renders as it does + * in a browser, hidden, with its submenus open. This reads the result and shows that + * natively. A pick clicks the row the HTML menu has for it, so a menu has one list of + * entries with one set of handlers and the two can't drift. + * + * A menu with something a native one can't show (a plugin's frame, a colour picker, + * a row with no text) stays HTML. So does anything marked data-native-menu="html". + */ + +import { getDesktopBridge } from './bridge'; +import type { DesktopNativeMenuItem } from './types'; + +export function nativeMenusAvailable(): boolean { + return !!getDesktopBridge()?.menu?.popup; +} + +export interface ReadMenu { + items: DesktopNativeMenuItem[]; + /** The HTML row behind each native item's id. */ + elements: Map; + /** There's something here a native menu can't show. */ + unsupported: boolean; +} + +const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]'; + +function isDisabled(el: HTMLElement): boolean { + return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true'; +} + +function labelOf(el: HTMLElement): string { + const named = el.getAttribute('aria-label') ?? el.querySelector('[data-menu-label]')?.textContent ?? el.textContent ?? ''; + return named.replace(/\s+/g, ' ').trim(); +} + +function isHidden(el: Element): boolean { + return typeof window.getComputedStyle === 'function' && window.getComputedStyle(el).display === 'none'; +} + +export function readMenu(root: HTMLElement): ReadMenu { + const elements = new Map(); + let unsupported = root.querySelector('[data-native-menu="html"], iframe, select, textarea, input[type="color"]') !== null; + let next = 1; + + const walk = (container: Element, into: DesktopNativeMenuItem[]): void => { + for (const child of Array.from(container.children)) { + if (!(child instanceof HTMLElement) || isHidden(child)) continue; + if (child.getAttribute('role') === 'separator') { + into.push({ type: 'separator' }); + continue; + } + if (child.dataset.submenuPanel !== undefined) continue; + if (child.dataset.contextMenuHeader !== undefined) { + const label = labelOf(child); + if (label) into.push({ id: `h${next++}`, label, enabled: false }); + continue; + } + if (child.dataset.submenuTrigger !== undefined) { + const label = labelOf(child); + if (isDisabled(child)) { + into.push({ id: `s${next++}`, label, enabled: false }); + continue; + } + const panel = child.parentElement?.querySelector(':scope > [data-submenu-panel]'); + if (!panel) { + unsupported = true; + continue; + } + const submenu: DesktopNativeMenuItem[] = []; + walk(panel, submenu); + into.push({ label, submenu }); + continue; + } + if (child.matches(ROW)) { + // A row that opens its own popup: there's nothing to click through to. + if (child.hasAttribute('aria-haspopup')) { + unsupported = true; + continue; + } + const label = labelOf(child); + if (!label) { + // A swatch or an icon says nothing in a native menu. + unsupported = true; + continue; + } + const id = `i${next++}`; + elements.set(id, child); + const role = child.getAttribute('role'); + into.push({ + id, + label, + enabled: !isDisabled(child), + ...(role === 'menuitemcheckbox' || role === 'menuitemradio' + ? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' } + : {}), + }); + continue; + } + // A search field over a list is no use where you can't type. + if (child instanceof HTMLInputElement) continue; + walk(child, into); + } + }; + + const items: DesktopNativeMenuItem[] = []; + walk(root, items); + return { items, elements, unsupported }; +} + +const SETTLE_MS = 30; +const SETTLE_LIMIT_MS = 400; + +/** + * The menu once it stopped changing. Entries can render a moment after the menu + * (the mail menu waits for its data), and a native menu can't grow once it's up. + */ +export async function readSettledMenu(root: HTMLElement, cancelled: () => boolean): Promise { + const started = Date.now(); + let previous = readMenu(root); + while (!cancelled() && Date.now() - started < SETTLE_LIMIT_MS) { + await new Promise((resolve) => setTimeout(resolve, SETTLE_MS)); + const current = readMenu(root); + if (current.items.length > 0 && JSON.stringify(current.items) === JSON.stringify(previous.items)) return current; + previous = current; + } + return previous; +} + +/** Shows it and clicks the row picked. Resolves when the menu has gone. */ +export async function showNativeMenu(read: ReadMenu, at?: { x: number; y: number }): Promise { + const popup = getDesktopBridge()?.menu?.popup; + if (!popup) return; + document.documentElement.dataset.nativeMenuOpen = ''; + try { + const picked = await popup(read.items, at); + if (picked) read.elements.get(picked)?.click(); + } finally { + delete document.documentElement.dataset.nativeMenuOpen; + } +} + +/** A native menu is up: the window's blur or a stray event says nothing about it. */ +export function nativeMenuIsOpen(): boolean { + return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined; +} diff --git a/lib/desktop/system-accent.ts b/lib/desktop/system-accent.ts new file mode 100644 index 000000000..49f575429 --- /dev/null +++ b/lib/desktop/system-accent.ts @@ -0,0 +1,69 @@ +/** + * The OS accent colour, when the user's theme is "system". Buttons and selection take + * it the way native controls do. Not under a theme the user chose, and not when the + * colour would be hard to see: the app's own stays then. + */ + +import { getDesktopBridge } from './bridge'; + +const VARIABLES = ['--color-primary', '--color-ring', '--color-primary-foreground'] as const; + +function channels(hex: string): [number, number, number] | null { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + if (!match) return null; + return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)]; +} + +function luminance([r, g, b]: [number, number, number]): number { + const [lr, lg, lb] = [r, g, b].map((value) => { + const c = value / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb; +} + +function contrast(a: number, b: number): number { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +/** Non-text contrast, WCAG 1.4.11. */ +const MIN_AGAINST_BACKGROUND = 3; +const MIN_TEXT = 4.5; + +/** + * The colours to use, or null for the app's own: the accent has to stand out from the + * page, and text on it has to be readable in either black or white. + */ +export function accentColours(accent: string, dark: boolean): { primary: string; foreground: string } | null { + const rgb = channels(accent); + if (!rgb) return null; + const own = luminance(rgb); + // The standard theme's page backgrounds. + const page = dark ? 0.0091 : 1; + if (contrast(own, page) < MIN_AGAINST_BACKGROUND) return null; + const onWhite = contrast(own, 1); + const onBlack = contrast(own, 0); + if (Math.max(onWhite, onBlack) < MIN_TEXT) return null; + return { primary: accent, foreground: onWhite >= MIN_TEXT ? '#ffffff' : '#000000' }; +} + +export interface AccentInput { + accent?: string; + /** Whether the page is dark now. */ + dark: boolean; + /** The user's theme setting is "system" and no installed theme is active. */ + followsSystem: boolean; + /** A contrast theme has its own colours and they win. */ + highContrast?: boolean; +} + +export function applySystemAccent({ accent, dark, followsSystem, highContrast }: AccentInput, root: HTMLElement = document.documentElement): void { + const colours = accent && followsSystem && !highContrast && getDesktopBridge() ? accentColours(accent, dark) : null; + if (!colours) { + for (const name of VARIABLES) root.style.removeProperty(name); + return; + } + root.style.setProperty('--color-primary', colours.primary); + root.style.setProperty('--color-ring', colours.primary); + root.style.setProperty('--color-primary-foreground', colours.foreground); +} From fb0eeca9c50b5a9d49fe1f0d189436d316df78d6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:49:25 +0100 Subject: [PATCH 06/45] fix: put the focus on the desktop menu strip once its names are on screen --- components/layout/window-title-bar.tsx | 18 +++-- lib/__tests__/window-title-bar.test.tsx | 95 ++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx index df2f7d335..c2fd2ce84 100644 --- a/components/layout/window-title-bar.tsx +++ b/components/layout/window-title-bar.tsx @@ -25,6 +25,8 @@ export function WindowTitleBar() { const [entries, setEntries] = useState(null); const stripRef = useRef(null); const previousFocus = useRef(null); + // What to do once the names are on screen: open that menu, or null to focus the first name. + const afterShow = useRef(undefined); const openMenu = useCallback((id: string) => { const button = stripRef.current?.querySelector(`[data-menu-id="${CSS.escape(id)}"]`); @@ -45,15 +47,19 @@ export function WindowTitleBar() { } void bar.entries().then((list) => { previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null; + afterShow.current = event.open ?? null; setEntries(list); - // Once the names are on screen. - window.setTimeout(() => { - if (event.open) openMenu(event.open); - else stripRef.current?.querySelector("button")?.focus(); - }, 0); }); }); - }, [openMenu]); + }, []); + + useEffect(() => { + const next = afterShow.current; + if (!entries || next === undefined) return; + afterShow.current = undefined; + if (next) openMenu(next); + else stripRef.current?.querySelector("button")?.focus(); + }, [entries, openMenu]); useEffect(() => watchTitlebarArea((next) => { applyTitlebarInset(next); diff --git a/lib/__tests__/window-title-bar.test.tsx b/lib/__tests__/window-title-bar.test.tsx index b0d4bb11b..e785ffb33 100644 --- a/lib/__tests__/window-title-bar.test.tsx +++ b/lib/__tests__/window-title-bar.test.tsx @@ -1,7 +1,7 @@ -import { act, cleanup, render } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { WindowTitleBar } from '@/components/layout/window-title-bar'; import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay'; @@ -30,6 +30,97 @@ afterEach(() => { document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR); }); +describe('the menu strip in the desktop app', () => { + type BarEvent = { visible: boolean; open?: string }; + + function withBar() { + let send: (event: BarEvent) => void = () => {}; + const open = vi.fn(); + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + menu: { + setState: () => {}, + bar: { + entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }], + open, + onEvent: (listener: (event: BarEvent) => void) => { + send = listener; + return () => {}; + }, + }, + }, + } as never; + return { open, send: (event: BarEvent) => send(event) }; + } + + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const overlayOn = () => { + Object.defineProperty(navigator, 'windowControlsOverlay', { + configurable: true, + value: Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 36 }) }), + }); + }; + + afterEach(() => { + delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay; + }); + + it('Alt shows the names with the focus on the first, and Escape gives it back', async () => { + overlayOn(); + const bar = withBar(); + const before = document.createElement('button'); + document.body.appendChild(before); + before.focus(); + render(); + await act(async () => bar.send({ visible: true })); + const file = await screen.findByRole('menuitem', { name: 'File' }); + await waitFor(() => expect(document.activeElement).toBe(file)); + fireEvent.keyDown(file, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' })); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(screen.queryByRole('menubar')).toBeNull(); + expect(document.activeElement).toBe(before); + before.remove(); + }); + + it('a name opens that menu natively', async () => { + overlayOn(); + const bar = withBar(); + render(); + await act(async () => bar.send({ visible: true })); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit' })); + expect(bar.open).toHaveBeenCalledWith('edit', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) })); + }); + + it('Alt and a letter opens that one right away', async () => { + overlayOn(); + const bar = withBar(); + render(); + await act(async () => bar.send({ visible: true, open: 'file' })); + await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything())); + }); + + it('the shell can take it away again', async () => { + overlayOn(); + const bar = withBar(); + render(); + await act(async () => bar.send({ visible: true })); + await screen.findByRole('menubar'); + await act(async () => bar.send({ visible: false })); + expect(screen.queryByRole('menubar')).toBeNull(); + }); + + it('with no bridge there is none, and the title stays', async () => { + overlayOn(); + render(); + expect(screen.queryByRole('menubar')).toBeNull(); + }); +}); + describe('the title bar', () => { it('shows nothing and touches nothing without the API', () => { installOverlay(undefined); From 42263e856dc8e46e21eb4c9542da2cf8e6139b75 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:54:17 +0100 Subject: [PATCH 07/45] fix: still notify about mail the local store hadn't got when the push arrived --- lib/desktop/__tests__/os-mail.test.ts | 21 +++++++++++++++++++++ lib/desktop/new-mail.ts | 6 ++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts index 1338162d0..ca3f25bf1 100644 --- a/lib/desktop/__tests__/os-mail.test.ts +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -166,6 +166,27 @@ describe('the watch', () => { watch.stop(); }); + it('a message the store has not got yet is not written off: its created event still tells it', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const held: Record = {}; + const fake = fakeClient(held, [{ created: ['a'], newState: 's2' }]); + const seen: string[] = []; + const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id))); + watch.seed(fake); + await flush(); + // The push got here first. The device answers, and it doesn't have the message. + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active'); + await flush(); + expect(seen).toEqual([]); + // The store has synced it now. + held.a = email('a'); + watch.noteCreated(fake, 'acc', ['a']); + await flush(); + expect(seen).toEqual(['a']); + watch.stop(); + }); + it('without a baseline the first push only sets one: old mail is not news', async () => { const { startNewMailWatch } = await import('../new-mail'); const { emitPushStateChange } = await import('@/lib/push-events'); diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts index 31a6568ea..efe23173d 100644 --- a/lib/desktop/new-mail.ts +++ b/lib/desktop/new-mail.ts @@ -81,9 +81,11 @@ export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWat 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; - for (const id of fresh) seen.push(`${accountId}/${id}`); - seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT)); 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)) From 3dc9e11b4db65071f63ed3b88abc05507c627014 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:32:51 +0100 Subject: [PATCH 08/45] test: give the store and OS integration test time for its first import --- lib/desktop/__tests__/os-store.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts index 7339a0aa4..cd876cfd2 100644 --- a/lib/desktop/__tests__/os-store.test.ts +++ b/lib/desktop/__tests__/os-store.test.ts @@ -4,6 +4,9 @@ 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 () => {})); From 41fcbba61c63d119f0a31763b336f0a0ddfc848a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:24:14 +0100 Subject: [PATCH 09/45] fix: keep sender and subject off the OS until they're known to be allowed, leave nothing on it after sign-out, and take files only where they're meant --- components/desktop/desktop-os-host.tsx | 10 +- components/email/email-composer.tsx | 7 +- components/email/email-viewer.tsx | 22 ++- .../protocol-launch-handler-provider.tsx | 2 + lib/desktop/__tests__/os-commands.test.ts | 30 +++- lib/desktop/__tests__/os-mail.test.ts | 11 ++ lib/desktop/__tests__/os-store.test.ts | 11 ++ lib/desktop/__tests__/os-window.test.tsx | 168 +++++++++++++++++- lib/desktop/bridge.ts | 5 + lib/desktop/mail-notifications.ts | 12 +- lib/desktop/os-commands.ts | 9 + lib/desktop/os-summary.ts | 26 ++- lib/desktop/power.ts | 24 ++- lib/desktop/types.ts | 11 +- lib/desktop/window-files.ts | 45 ++++- lib/offline/offline-mode.ts | 3 + lib/sign-out-cleanup.ts | 2 + locales/ar/common.json | 31 ++++ locales/ca/common.json | 31 ++++ locales/cs/common.json | 31 ++++ locales/da/common.json | 31 ++++ locales/de/common.json | 31 ++++ locales/en/common.json | 31 ++++ locales/es/common.json | 31 ++++ locales/et/common.json | 31 ++++ locales/fa/common.json | 31 ++++ locales/fr/common.json | 31 ++++ locales/he/common.json | 31 ++++ locales/hu/common.json | 31 ++++ locales/it/common.json | 31 ++++ locales/ja/common.json | 31 ++++ locales/ko/common.json | 31 ++++ locales/lv/common.json | 31 ++++ locales/mn/common.json | 31 ++++ locales/nb/common.json | 31 ++++ locales/nl/common.json | 31 ++++ locales/pl/common.json | 31 ++++ locales/pt/common.json | 31 ++++ locales/ro/common.json | 31 ++++ locales/ru/common.json | 31 ++++ locales/sk/common.json | 31 ++++ locales/th/common.json | 31 ++++ locales/tr/common.json | 31 ++++ locales/uk/common.json | 31 ++++ locales/vi/common.json | 31 ++++ locales/zh-TW/common.json | 31 ++++ locales/zh/common.json | 31 ++++ 47 files changed, 1277 insertions(+), 51 deletions(-) diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx index c8d2755d8..2e4ea7fd6 100644 --- a/components/desktop/desktop-os-host.tsx +++ b/components/desktop/desktop-os-host.tsx @@ -4,7 +4,8 @@ import { useEffect, useRef } from "react"; import { useLocale, useTranslations } from "next-intl"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { getDesktopBridge } from "@/lib/desktop/bridge"; -import { startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications"; +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"; @@ -49,7 +50,10 @@ export function DesktopOsHost() { labelsRef.current = labels; useEffect(() => { - if (!getDesktopBridge()?.os) return; + 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); @@ -64,7 +68,7 @@ export function DesktopOsHost() { const event = "eventId" in view; void getDesktopBridge()?.os?.notify({ id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`, - title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"), + title: (contentHiddenFor(useAuthStore.getState().activeAccountId) ? "" : event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"), body: "", context: "{}", }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 4c02f4eac..f0dab3ae6 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2650,8 +2650,11 @@ export function EmailComposer({ resetDragTimeout(); }, [resetDragTimeout]); - // The desktop app: files dropped or pasted anywhere on the window come here. - useEffect(() => setComposerFileTarget((files) => void addFiles(files)), [addFiles]); + // 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]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9dbde37b4..22d9f7730 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -685,15 +685,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl const source = useMemo(() => ({ name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', - getBlob: async () => { - if (attachment.blobId && client) { - return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); - } - const bytes = attachment.tnefData ?? (attachment.decryptedAttachment ? getAttachmentContentBytes(attachment.decryptedAttachment) : null); - 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) }); - }, + // A decrypted attachment doesn't go through the app's temporary folder or the clipboard: it + // stays with the browser's drag, which only writes it where the user drops it. + ...(attachment.decryptedAttachment ? {} : { + getBlob: async () => { + if (attachment.blobId && client) { + return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); + } + const bytes = attachment.tnefData; + if (!bytes || bytes.byteLength === 0) return null; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + return new Blob([buffer], { type: inertBlobType(attachment.type) }); + }, + }), getBlobUrl: async () => { if (attachment.blobId && client) { try { diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 0078cd8bf..f3940efaa 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -111,6 +111,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle const router = useRouter(); const pathname = usePathname(); const tLaunch = useTranslations("pwaLaunch"); + const tDesktop = useTranslations("desktopOs"); const sentToTarget = useRef(new Set()); const { dialogProps, confirm } = useConfirmDialog(); const [offer, setOffer] = useState(null); @@ -213,6 +214,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle 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 }, []); diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts index 465180c64..d3f717f64 100644 --- a/lib/desktop/__tests__/os-commands.test.ts +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -33,7 +33,8 @@ async function fingerprint() { const leaveFor = vi.fn(); const push = vi.fn(); const notifyFailed = vi.fn(); -const env = { leaveFor, push, notifyFailed }; +const notifyReplyLost = vi.fn(); +const env = { leaveFor, push, notifyFailed, notifyReplyLost }; const focus = vi.fn(); async function run(command: DesktopOsCommand) { @@ -191,6 +192,33 @@ describe('a toast\'s buttons', () => { 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(); diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts index ca3f25bf1..da28971f6 100644 --- a/lib/desktop/__tests__/os-mail.test.ts +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -87,6 +87,8 @@ beforeEach(async () => { }); const { setAppLockCheck } = await import('../app-lock-state'); setAppLockCheck(null); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true }); }); afterEach(() => { @@ -266,6 +268,15 @@ describe('what the notification says', () => { 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); diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts index cd876cfd2..e09aee90d 100644 --- a/lib/desktop/__tests__/os-store.test.ts +++ b/lib/desktop/__tests__/os-store.test.ts @@ -49,6 +49,17 @@ describe('the store and the OS integration', () => { 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'); + await wipeAccount('alice@one').catch(() => {}); + expect(clearSurfaces).toHaveBeenCalled(); + window.bulwarkDesktop = undefined; + }); + it('other collections are not mail', async () => { const { setStoreCreatedListener } = await import('../system-events'); const listener = vi.fn(); diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index 2e005476f..b6df496bb 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -159,7 +159,7 @@ describe('files on the window', () => { setBridge({}); const stop = startWindowFiles(); const target = vi.fn(); - const clear = setComposerFileTarget(target); + const clear = setComposerFileTarget(target, () => true); window.dispatchEvent(dropEvent('drop', [file])); expect(target).toHaveBeenCalledWith([file]); expect(takeHandoff('compose')).toBeNull(); @@ -167,6 +167,20 @@ describe('files on the window', () => { 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'); @@ -174,7 +188,7 @@ describe('files on the window', () => { const stop = startWindowFiles(); const paste = (files: File[]) => { const event = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: { files } }); + Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } }); window.dispatchEvent(event); return event; }; @@ -185,6 +199,54 @@ describe('files on the window', () => { 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'); @@ -247,6 +309,23 @@ describe('dragging an attachment out', () => { 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: '' } }; @@ -313,15 +392,52 @@ describe('waking up', () => { stop(); }); - it('a network change counts as waking', async () => { - const connection = new EventTarget(); - vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection })); + 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); - await new Promise((resolve) => setTimeout(resolve, 0)); connection.dispatchEvent(new Event('change')); - expect(wake).toHaveBeenCalled(); + 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(); }); @@ -357,6 +473,8 @@ describe('the summary', () => { 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 () => { @@ -374,6 +492,42 @@ describe('the summary', () => { 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'); diff --git a/lib/desktop/bridge.ts b/lib/desktop/bridge.ts index 922a5c1b5..33e36bd81 100644 --- a/lib/desktop/bridge.ts +++ b/lib/desktop/bridge.ts @@ -19,6 +19,11 @@ export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signe getDesktopBridge()?.servers?.forget?.(serverUrl, reason); } +/** Nothing of an account stays on the OS's surfaces after a sign-out or a wipe. */ +export function clearDesktopSurfaces(): void { + getDesktopBridge()?.os?.clearSurfaces?.(); +} + /** True in any window of the desktop app, standalone or on an instance. */ export function isDesktopApp(): boolean { return getDesktopBridge() !== null; diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts index 05b3303ba..12cd12e5d 100644 --- a/lib/desktop/mail-notifications.ts +++ b/lib/desktop/mail-notifications.ts @@ -21,6 +21,7 @@ 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'; @@ -72,17 +73,22 @@ async function senderInfo(client: IJMAPClient): Promise { }; } -function withoutContent(sender: Sender): boolean { +/** 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 (!sender.entryId) return true; + if (!accountEntryId || !usePolicyStore.getState().loaded) return true; try { - return devicesForAccount(sender.entryId).pushWithoutContent; + 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 || ''; diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index 8c7332225..534ff7544 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -5,6 +5,7 @@ * 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'; @@ -25,6 +26,8 @@ export interface OsCommandEnv { 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. */ @@ -77,6 +80,7 @@ async function openFromNotification(context: MailNotificationContext, env: OsCom 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); } @@ -89,6 +93,11 @@ function runAction(request: NotificationActionRequest): Promise { + // An old toast's Delete doesn't act behind the lock, and its message doesn't open over it. + if (isAppLocked()) { + showWindow(); + return; + } const context = parseContext(rawContext); if (action === 'reply') { await openFromNotification(context, env, { text: reply ?? '' }); diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts index 12d014051..599dd942a 100644 --- a/lib/desktop/os-summary.ts +++ b/lib/desktop/os-summary.ts @@ -6,9 +6,8 @@ */ import { getDesktopBridge } from '@/lib/desktop/bridge'; -import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { contentHiddenFor } from '@/lib/desktop/mail-notifications'; import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types'; -import { devicesForAccount } from '@/lib/device-policy'; import type { CalendarEvent, Email } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; @@ -16,6 +15,7 @@ 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; @@ -46,12 +46,10 @@ export function formatEventTime(start: number, now: number, locale: string | und } export async function readSummary(locale: string | undefined, now = Date.now()): Promise { - if (isAppLocked()) return EMPTY_SUMMARY; 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) return EMPTY_SUMMARY; - if (devicesForAccount(entry.id).pushWithoutContent) return EMPTY_SUMMARY; + 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[] = []; @@ -91,12 +89,14 @@ export function startOsSummary(locale: () => string | undefined): { refresh: () const os = getDesktopBridge()?.os; if (!os) return { refresh: () => {}, stop: () => {} }; let timer: ReturnType | null = null; - let stale = false; + /** 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 (!stale) os.setSummary(summary); + if (mine === generation) os.setSummary(summary); }, (error) => { console.warn('Could not read the tray summary:', error); }); @@ -113,16 +113,24 @@ export function startOsSummary(locale: () => string | undefined): { refresh: () refresh(); }); const stopAuth = useAuthStore.subscribe((state, previous) => { - if (state.activeAccountId !== previous.activeAccountId || state.isAuthenticated !== previous.isAuthenticated) refresh(); + 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() { - stale = true; + generation += 1; stopEmail(); stopAuth(); + stopPolicy(); clearInterval(interval); if (timer) clearTimeout(timer); }, diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts index 44d429dfc..1e36e52ae 100644 --- a/lib/desktop/power.ts +++ b/lib/desktop/power.ts @@ -13,12 +13,13 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events'; import { usePolicyStore } from '@/stores/policy-store'; -/** A resume and an unlock usually come together. */ -const WAKE_REPEAT_MS = 2000; +/** 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')); - window.dispatchEvent(new Event('online')); + // 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(); @@ -38,11 +39,20 @@ export function startPowerWatch(): () => void { 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. - const connection = (navigator as unknown as { connection?: EventTarget }).connection; - connection?.addEventListener?.('change', wakeOnce); + // 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', wakeOnce); + connection?.removeEventListener?.('change', onConnection); }; } diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 501cc503a..859f2ea97 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -168,7 +168,7 @@ export interface DesktopOsNotification { export interface DesktopOsNotifyResult { shown: boolean; - reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid'; + reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid' | 'failed'; /** The page should play the sound the user picked. */ playSound: boolean; } @@ -214,8 +214,13 @@ export interface DesktopOsBridge { 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 clears the tray, jump list and toasts and strips what comes through. */ + /** + * App lock's state. Main counts as locked until the main window's page says otherwise, + * and clears the tray, jump list and toasts and strips what comes through. + */ setLocked: (locked: boolean) => void; + /** Sign-out or a wipe: delivered toasts, badge, tray and jump list go, and temporary files. */ + clearSurfaces: () => void; notify: (notification: DesktopOsNotification) => Promise; onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void; /** Writes the files to a private folder and starts a drag. Only with the mouse held. */ @@ -225,6 +230,8 @@ export interface DesktopOsBridge { defaultAppStatus: () => Promise; /** Offers the app to the OS and opens where the user chooses. */ openDefaultAppSettings: () => Promise; +} + export type DesktopKeystoreHash = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'; export interface DesktopKeystoreCertificate { diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts index c9b95221a..63a67630b 100644 --- a/lib/desktop/window-files.ts +++ b/lib/desktop/window-files.ts @@ -8,29 +8,58 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { handOffDraft } from '@/lib/launch-handoff'; -type ComposerTarget = (files: File[]) => unknown; +interface ComposerTarget { + take: (files: File[]) => unknown; + /** The Pro shell keeps composers mounted on tabs out of sight. */ + visible: () => boolean; +} -let composerTarget: ComposerTarget | null = null; +const composerTargets: ComposerTarget[] = []; /** Our own native drag passing over our own window isn't a new file. */ let ownDrag = false; -/** The open composer takes files itself. No effect outside the desktop app: only this file reads it. */ -export function setComposerFileTarget(target: ComposerTarget): () => void { - composerTarget = target; +/** 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 () => { - if (composerTarget === target) composerTarget = null; + 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 { - if (composerTarget) composerTarget(files); + const composer = visibleComposer(); + if (composer) composer.take(files); else handOffDraft({ files }); } +function isEditable(target: EventTarget | null): boolean { + const element = target as Partial & { closest?: (selector: string) => Element | null } | null; + return !!element?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]'); +} + +/** + * Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a + * picture of them, and a field that takes text wants the text. The one editable place a + * bare file belongs in is the composer it's being pasted into. + */ +function pasteIsForUs(event: ClipboardEvent): boolean { + const types = [...(event.clipboardData?.types ?? [])]; + if (types.includes('text/plain') || types.includes('text/html')) return false; + if (!isEditable(event.target)) return true; + const inComposer = !!(event.target as Partial | null)?.closest?.('[data-testid="email-composer"]'); + return inComposer && !!visibleComposer(); +} + function hasFiles(event: DragEvent): boolean { return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files'); } @@ -51,7 +80,7 @@ export function startWindowFiles(): () => void { const onPaste = (event: ClipboardEvent) => { if (event.defaultPrevented) return; const files = Array.from(event.clipboardData?.files ?? []); - if (files.length === 0) return; + if (files.length === 0 || !pasteIsForUs(event)) return; event.preventDefault(); take(files); }; diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index d71993589..e138070f1 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -62,6 +62,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from ' import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; +import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events'; export type OfflineNotice = @@ -807,6 +808,7 @@ async function nameLost(scope: string, stillSignedIn: boolean): Promise { } export async function wipeAccount(account: string): Promise { + clearDesktopSurfaces(); const timer = syncTimers.get(account); if (timer) clearTimeout(timer); syncTimers.delete(account); @@ -838,6 +840,7 @@ export async function wipeAccount(account: string): Promise { /** Nothing of offline mode stays on this device. */ export async function wipeEverything(): Promise { + clearDesktopSurfaces(); for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); writeEnabled([]); diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 531c99f7f..67ff5ea9c 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -1,6 +1,7 @@ import { fileStorage } from '@/lib/plugin-storage'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; +import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; import { sweepPrivateExports } from '@/lib/mail-export/sinks'; import { purgeStoreFiles } from '@/lib/store/client/purge'; @@ -138,6 +139,7 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { } // Files and links on their way into a draft or an import. clearHandoffs(); + clearDesktopSurfaces(); for (const cleanup of signedOutCleanups) { try { cleanup(options); diff --git a/locales/ar/common.json b/locales/ar/common.json index 654025307..911ff01cf 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "تم نقل هذه الرسالة إلى {folder}.", "window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا." }, + "desktopOs": { + "section_title": "تطبيق سطح المكتب", + "section_description": "طريقة عمل التطبيق مع حاسوبك.", + "close_to_tray_label": "الاستمرار في العمل في شريط النظام عند الإغلاق", + "close_to_tray_description": "يستمر وصول البريد الجديد والتذكيرات بعد إغلاق النافذة.", + "start_at_login_label": "البدء عند تسجيل الدخول إلى الحاسوب", + "start_at_login_description": "يبدأ التطبيق تلقائيًا عند تسجيل الدخول.", + "start_minimized_label": "البدء في شريط النظام", + "start_minimized_description": "لا تُفتح نافذة عند تسجيل الدخول. استخدم أيقونة شريط النظام لفتحه.", + "attention_label": "وميض شريط المهام عند وصول بريد جديد", + "attention_mac_label": "قفز أيقونة Dock عند وصول بريد جديد", + "attention_description": "فقط عندما لا يكون التطبيق في المقدمة.", + "quiet_hours_label": "ساعات الهدوء", + "quiet_hours_description": "لا إشعارات ولا أصوات في هذه الأوقات. يستمر وصول البريد.", + "quiet_hours_from": "من", + "quiet_hours_to": "إلى", + "show_shortcut_label": "إظهار التطبيق باختصار لوحة المفاتيح", + "show_shortcut_description": "{keys} يُظهر التطبيق في المقدمة من أي تطبيق آخر.", + "show_shortcut_taken": "يستخدم تطبيق آخر {keys}. أغلق ذلك التطبيق، أو أوقف هذا الخيار ثم شغّله مرة أخرى.", + "default_app_label": "تطبيق البريد الافتراضي", + "default_app_description": "فتح روابط البريد وملفات ‎.eml و‎.ics و‎.vcf بهذا التطبيق.", + "default_app_is_default": "هذا التطبيق يفتح روابط البريد لديك.", + "default_app_button": "اختيار التطبيق الافتراضي…", + "new_mail": "بريد جديد", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "نسخ كملف", + "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.", + "copy_failed": "تعذّر نسخ الملف.", + "reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك." + }, "desktop_sign_in": { "browser_button": "سجّل الدخول باستخدام المتصفح", "browser_waiting": "أكمل تسجيل الدخول في المتصفح", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0a9eb6fb2..275c62df5 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Aquest missatge s’ha mogut a {folder}.", "window_not_ready": "La finestra nova no s’ha obert a temps. L’esborrany es queda aquí." }, + "desktopOs": { + "section_title": "Aplicació d'escriptori", + "section_description": "Com funciona l'aplicació amb l'ordinador.", + "close_to_tray_label": "Continua a la safata en tancar", + "close_to_tray_description": "El correu nou i els recordatoris continuen arribant després de tancar la finestra.", + "start_at_login_label": "Inicia en entrar a l'ordinador", + "start_at_login_description": "L'aplicació s'inicia sola quan entreu.", + "start_minimized_label": "Inicia a la safata", + "start_minimized_description": "No s'obre cap finestra en entrar. Obriu-la amb la icona de la safata.", + "attention_label": "Fes parpellejar la barra de tasques amb correu nou", + "attention_mac_label": "Fes botar la icona del Dock amb correu nou", + "attention_description": "Només quan l'aplicació no és al davant.", + "quiet_hours_label": "Hores de silenci", + "quiet_hours_description": "Cap notificació ni so en aquestes hores. El correu continua arribant.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'aplicació amb una drecera de teclat", + "show_shortcut_description": "{keys} porta l'aplicació al davant des de qualsevol altra aplicació.", + "show_shortcut_taken": "Una altra aplicació fa servir {keys}. Tanqueu-la o desactiveu i torneu a activar això.", + "default_app_label": "Aplicació de correu predeterminada", + "default_app_description": "Obre els enllaços de correu i els fitxers .eml, .ics i .vcf amb aquesta aplicació.", + "default_app_is_default": "Aquesta aplicació obre els vostres enllaços de correu.", + "default_app_button": "Tria l'aplicació predeterminada…", + "new_mail": "Correu nou", + "no_subject": "(sense assumpte)", + "action_delete": "Suprimeix", + "copy_as_file": "Copia com a fitxer", + "copied": "Fitxer copiat. El podeu enganxar en una carpeta.", + "copy_failed": "No s'ha pogut copiar el fitxer.", + "reply_lost": "No s'ha pogut conservar la resposta que heu escrit. Obriu el missatge i responeu des d'allà." + }, "desktop_sign_in": { "browser_button": "Inicia la sessió amb el navegador", "browser_waiting": "Acaba d'iniciar la sessió al navegador", diff --git a/locales/cs/common.json b/locales/cs/common.json index 18f0c03ee..d818f1a13 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Tato zpráva byla přesunuta do {folder}.", "window_not_ready": "Nové okno se neotevřelo včas. Koncept zůstává zde." }, + "desktopOs": { + "section_title": "Desktopová aplikace", + "section_description": "Jak aplikace spolupracuje s vaším počítačem.", + "close_to_tray_label": "Po zavření běžet dál v oznamovací oblasti", + "close_to_tray_description": "Nová pošta a připomenutí chodí i po zavření okna.", + "start_at_login_label": "Spustit po přihlášení k počítači", + "start_at_login_description": "Aplikace se po přihlášení spustí sama.", + "start_minimized_label": "Spustit v oznamovací oblasti", + "start_minimized_description": "Po přihlášení se neotevře žádné okno. Otevřete ji ikonou v oznamovací oblasti.", + "attention_label": "Při nové poště blikat na hlavním panelu", + "attention_mac_label": "Při nové poště poskakovat ikonou v Docku", + "attention_description": "Jen když aplikace není v popředí.", + "quiet_hours_label": "Doba klidu", + "quiet_hours_description": "V tuto dobu žádná oznámení ani zvuky. Pošta dál přichází.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobrazit aplikaci klávesovou zkratkou", + "show_shortcut_description": "{keys} přenese aplikaci do popředí z jakékoli jiné aplikace.", + "show_shortcut_taken": "{keys} používá jiná aplikace. Zavřete ji, nebo tuto volbu vypněte a znovu zapněte.", + "default_app_label": "Výchozí poštovní aplikace", + "default_app_description": "Otevírat poštovní odkazy a soubory .eml, .ics a .vcf v této aplikaci.", + "default_app_is_default": "Tato aplikace otevírá vaše poštovní odkazy.", + "default_app_button": "Vybrat výchozí aplikaci…", + "new_mail": "Nová pošta", + "no_subject": "(bez předmětu)", + "action_delete": "Smazat", + "copy_as_file": "Kopírovat jako soubor", + "copied": "Soubor zkopírován. Můžete ho vložit do složky.", + "copy_failed": "Soubor se nepodařilo zkopírovat.", + "reply_lost": "Napsanou odpověď se nepodařilo zachovat. Otevřete zprávu a odpovězte v ní." + }, "desktop_sign_in": { "browser_button": "Přihlásit se v prohlížeči", "browser_waiting": "Dokončete přihlášení v prohlížeči", diff --git a/locales/da/common.json b/locales/da/common.json index 5578eeca0..fa38d0449 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Denne besked er flyttet til {folder}.", "window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her." }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Sådan arbejder appen sammen med din computer.", + "close_to_tray_label": "Fortsæt i systembakken ved lukning", + "close_to_tray_description": "Ny post og påmindelser bliver ved med at komme, når du lukker vinduet.", + "start_at_login_label": "Start, når du logger på computeren", + "start_at_login_description": "Appen starter selv, når du logger på.", + "start_minimized_label": "Start i systembakken", + "start_minimized_description": "Der åbnes intet vindue ved logon. Åbn appen med ikonet i systembakken.", + "attention_label": "Blink i proceslinjen ved ny post", + "attention_mac_label": "Lad Dock-ikonet hoppe ved ny post", + "attention_description": "Kun når appen ikke er forrest.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen notifikationer eller lyde i dette tidsrum. Post kommer stadig.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en tastaturgenvej", + "show_shortcut_description": "{keys} henter appen frem fra enhver anden app.", + "show_shortcut_taken": "En anden app bruger {keys}. Luk den app, eller slå dette fra og til igen.", + "default_app_label": "Standardmailapp", + "default_app_description": "Åbn maillinks og .eml-, .ics- og .vcf-filer med denne app.", + "default_app_is_default": "Denne app åbner dine maillinks.", + "default_app_button": "Vælg standardapp…", + "new_mail": "Ny post", + "no_subject": "(intet emne)", + "action_delete": "Slet", + "copy_as_file": "Kopiér som fil", + "copied": "Filen er kopieret. Du kan indsætte den i en mappe.", + "copy_failed": "Filen kunne ikke kopieres.", + "reply_lost": "Dit skrevne svar kunne ikke gemmes. Åbn beskeden, og svar der." + }, "desktop_sign_in": { "browser_button": "Log ind med din browser", "browser_waiting": "Gør login færdigt i din browser", diff --git a/locales/de/common.json b/locales/de/common.json index efb641f2b..336c82000 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.", "window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier." }, + "desktopOs": { + "section_title": "Desktop-App", + "section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.", + "close_to_tray_label": "Beim Schließen im Infobereich weiterlaufen", + "close_to_tray_description": "Neue E-Mails und Erinnerungen kommen auch dann an, wenn Sie das Fenster schließen.", + "start_at_login_label": "Beim Anmelden am Computer starten", + "start_at_login_description": "Die App startet von selbst, wenn Sie sich anmelden.", + "start_minimized_label": "Im Infobereich starten", + "start_minimized_description": "Beim Anmelden öffnet sich kein Fenster. Öffnen Sie die App über das Symbol im Infobereich.", + "attention_label": "Taskleiste bei neuen E-Mails blinken lassen", + "attention_mac_label": "Dock-Symbol bei neuen E-Mails hüpfen lassen", + "attention_description": "Nur wenn die App nicht im Vordergrund ist.", + "quiet_hours_label": "Ruhezeiten", + "quiet_hours_description": "In dieser Zeit keine Benachrichtigungen und Töne. E-Mails kommen trotzdem an.", + "quiet_hours_from": "Von", + "quiet_hours_to": "bis", + "show_shortcut_label": "App mit einer Tastenkombination anzeigen", + "show_shortcut_description": "{keys} holt die App aus jeder anderen App nach vorne.", + "show_shortcut_taken": "Eine andere App verwendet {keys}. Schließen Sie diese App oder schalten Sie das hier aus und wieder ein.", + "default_app_label": "Standard-E-Mail-App", + "default_app_description": "Mail-Links und .eml-, .ics- und .vcf-Dateien mit dieser App öffnen.", + "default_app_is_default": "Diese App öffnet Ihre Mail-Links.", + "default_app_button": "Standard-App auswählen…", + "new_mail": "Neue E-Mail", + "no_subject": "(kein Betreff)", + "action_delete": "Löschen", + "copy_as_file": "Als Datei kopieren", + "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.", + "copy_failed": "Die Datei konnte nicht kopiert werden.", + "reply_lost": "Ihre eingegebene Antwort konnte nicht übernommen werden. Öffnen Sie die Nachricht und antworten Sie dort." + }, "desktop_sign_in": { "browser_button": "Mit dem Browser anmelden", "browser_waiting": "Schließen Sie die Anmeldung im Browser ab", diff --git a/locales/en/common.json b/locales/en/common.json index 7728594e1..7ec268e1c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "This message was moved to {folder}.", "window_not_ready": "The new window did not open in time. Your draft stays here." }, + "desktopOs": { + "section_title": "Desktop app", + "section_description": "How the app works with your computer.", + "close_to_tray_label": "Keep running in the tray when closed", + "close_to_tray_description": "New mail and reminders keep coming after you close the window.", + "start_at_login_label": "Start when you sign in to your computer", + "start_at_login_description": "The app starts by itself when you sign in.", + "start_minimized_label": "Start in the tray", + "start_minimized_description": "No window opens at sign-in. Use the tray icon to open it.", + "attention_label": "Flash the taskbar on new mail", + "attention_mac_label": "Bounce the Dock icon on new mail", + "attention_description": "Only when the app is not in front.", + "quiet_hours_label": "Quiet hours", + "quiet_hours_description": "No notifications or sounds at these times. Mail still arrives.", + "quiet_hours_from": "From", + "quiet_hours_to": "to", + "show_shortcut_label": "Show the app with a keyboard shortcut", + "show_shortcut_description": "{keys} brings the app to the front, from any other app.", + "show_shortcut_taken": "Another app is using {keys}. Close that app, or turn this off and on again.", + "default_app_label": "Default mail app", + "default_app_description": "Open mail links and .eml, .ics and .vcf files with this app.", + "default_app_is_default": "This app opens your mail links.", + "default_app_button": "Choose default app…", + "new_mail": "New mail", + "no_subject": "(no subject)", + "action_delete": "Delete", + "copy_as_file": "Copy as file", + "copied": "File copied. You can paste it into a folder.", + "copy_failed": "The file could not be copied.", + "reply_lost": "Your typed reply could not be kept. Open the message and reply there." + }, "desktop_sign_in": { "browser_button": "Sign in with your browser", "browser_waiting": "Finish signing in in your browser", diff --git a/locales/es/common.json b/locales/es/common.json index 5a2af15a4..217aecbe4 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Este mensaje se movió a {folder}.", "window_not_ready": "La ventana nueva no se abrió a tiempo. Tu borrador se queda aquí." }, + "desktopOs": { + "section_title": "Aplicación de escritorio", + "section_description": "Cómo funciona la aplicación con tu ordenador.", + "close_to_tray_label": "Seguir en la bandeja al cerrar", + "close_to_tray_description": "El correo nuevo y los recordatorios siguen llegando después de cerrar la ventana.", + "start_at_login_label": "Iniciar al entrar en el ordenador", + "start_at_login_description": "La aplicación se inicia sola cuando entras.", + "start_minimized_label": "Iniciar en la bandeja", + "start_minimized_description": "No se abre ninguna ventana al entrar. Ábrela con el icono de la bandeja.", + "attention_label": "Hacer parpadear la barra de tareas con correo nuevo", + "attention_mac_label": "Hacer rebotar el icono del Dock con correo nuevo", + "attention_description": "Solo cuando la aplicación no está delante.", + "quiet_hours_label": "Horas de silencio", + "quiet_hours_description": "Sin notificaciones ni sonidos en estas horas. El correo sigue llegando.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostrar la aplicación con un atajo de teclado", + "show_shortcut_description": "{keys} trae la aplicación al frente desde cualquier otra.", + "show_shortcut_taken": "Otra aplicación está usando {keys}. Ciérrala, o desactiva y vuelve a activar esta opción.", + "default_app_label": "Aplicación de correo predeterminada", + "default_app_description": "Abrir los enlaces de correo y los archivos .eml, .ics y .vcf con esta aplicación.", + "default_app_is_default": "Esta aplicación abre tus enlaces de correo.", + "default_app_button": "Elegir aplicación predeterminada…", + "new_mail": "Correo nuevo", + "no_subject": "(sin asunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como archivo", + "copied": "Archivo copiado. Puedes pegarlo en una carpeta.", + "copy_failed": "No se pudo copiar el archivo.", + "reply_lost": "No se pudo conservar la respuesta que escribiste. Abre el mensaje y responde desde allí." + }, "desktop_sign_in": { "browser_button": "Iniciar sesión con el navegador", "browser_waiting": "Termina de iniciar sesión en el navegador", diff --git a/locales/et/common.json b/locales/et/common.json index b5ce8e291..82feaa2d2 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "See kiri teisaldati kausta {folder}.", "window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia." }, + "desktopOs": { + "section_title": "Töölauarakendus", + "section_description": "Kuidas rakendus sinu arvutiga koos töötab.", + "close_to_tray_label": "Sulgemisel jätka salves", + "close_to_tray_description": "Uus kiri ja meeldetuletused saabuvad ka pärast akna sulgemist.", + "start_at_login_label": "Käivita arvutisse sisselogimisel", + "start_at_login_description": "Rakendus käivitub sisselogimisel ise.", + "start_minimized_label": "Käivita salves", + "start_minimized_description": "Sisselogimisel akent ei avata. Ava rakendus salve ikoonist.", + "attention_label": "Vilguta tegumiriba uue kirja korral", + "attention_mac_label": "Põrgata Docki ikooni uue kirja korral", + "attention_description": "Ainult siis, kui rakendus pole esiplaanil.", + "quiet_hours_label": "Vaikne aeg", + "quiet_hours_description": "Sel ajal teavitusi ega helisid pole. Kirjad saabuvad ikka.", + "quiet_hours_from": "Alates", + "quiet_hours_to": "kuni", + "show_shortcut_label": "Näita rakendust kiirklahviga", + "show_shortcut_description": "{keys} toob rakenduse esiplaanile igast teisest rakendusest.", + "show_shortcut_taken": "{keys} on teise rakenduse kasutuses. Sulge see rakendus või lülita see valik välja ja uuesti sisse.", + "default_app_label": "Vaikimisi e-posti rakendus", + "default_app_description": "Ava e-posti lingid ning .eml-, .ics- ja .vcf-failid selle rakendusega.", + "default_app_is_default": "See rakendus avab sinu e-posti lingid.", + "default_app_button": "Vali vaikerakendus…", + "new_mail": "Uus kiri", + "no_subject": "(teema puudub)", + "action_delete": "Kustuta", + "copy_as_file": "Kopeeri failina", + "copied": "Fail on kopeeritud. Saad selle kausta kleepida.", + "copy_failed": "Faili ei saanud kopeerida.", + "reply_lost": "Kirjutatud vastust ei saanud säilitada. Ava kiri ja vasta seal." + }, "desktop_sign_in": { "browser_button": "Logi sisse brauseriga", "browser_waiting": "Lõpeta sisselogimine brauseris", diff --git a/locales/fa/common.json b/locales/fa/common.json index 22a2c1d43..1b1b3e88f 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "این پیام به {folder} منتقل شد.", "window_not_ready": "پنجرهٔ جدید به‌موقع باز نشد. پیش‌نویس شما همین‌جا می‌ماند." }, + "desktopOs": { + "section_title": "برنامه دسکتاپ", + "section_description": "نحوه کار برنامه با رایانه شما.", + "close_to_tray_label": "هنگام بستن در سینی سیستم اجرا بماند", + "close_to_tray_description": "نامه‌های جدید و یادآوری‌ها پس از بستن پنجره هم می‌رسند.", + "start_at_login_label": "هنگام ورود به رایانه شروع شود", + "start_at_login_description": "برنامه هنگام ورود خودش شروع می‌شود.", + "start_minimized_label": "شروع در سینی سیستم", + "start_minimized_description": "هنگام ورود پنجره‌ای باز نمی‌شود. با نماد سینی سیستم آن را باز کنید.", + "attention_label": "چشمک نوار وظیفه هنگام نامه جدید", + "attention_mac_label": "جهش نماد Dock هنگام نامه جدید", + "attention_description": "فقط وقتی برنامه در جلو نیست.", + "quiet_hours_label": "ساعات سکوت", + "quiet_hours_description": "در این ساعات اعلان و صدایی نیست. نامه‌ها همچنان می‌رسند.", + "quiet_hours_from": "از", + "quiet_hours_to": "تا", + "show_shortcut_label": "نمایش برنامه با میان‌بر صفحه‌کلید", + "show_shortcut_description": "{keys} برنامه را از هر برنامه دیگری به جلو می‌آورد.", + "show_shortcut_taken": "برنامه دیگری از {keys} استفاده می‌کند. آن را ببندید، یا این گزینه را خاموش و دوباره روشن کنید.", + "default_app_label": "برنامه پیش‌فرض ایمیل", + "default_app_description": "باز کردن پیوندهای ایمیل و پرونده‌های ‎.eml، ‎.ics و ‎.vcf با این برنامه.", + "default_app_is_default": "این برنامه پیوندهای ایمیل شما را باز می‌کند.", + "default_app_button": "انتخاب برنامه پیش‌فرض…", + "new_mail": "نامه جدید", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "رونوشت به‌صورت پرونده", + "copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.", + "copy_failed": "رونوشت پرونده ممکن نشد.", + "reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید." + }, "desktop_sign_in": { "browser_button": "ورود با مرورگر", "browser_waiting": "ورود را در مرورگر کامل کنید", diff --git a/locales/fr/common.json b/locales/fr/common.json index e30800b16..864c7c969 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Ce message a été déplacé vers {folder}.", "window_not_ready": "La nouvelle fenêtre ne s’est pas ouverte à temps. Votre brouillon reste ici." }, + "desktopOs": { + "section_title": "Application de bureau", + "section_description": "Comment l'application fonctionne avec votre ordinateur.", + "close_to_tray_label": "Rester dans la zone de notification à la fermeture", + "close_to_tray_description": "Le courrier et les rappels continuent d'arriver après la fermeture de la fenêtre.", + "start_at_login_label": "Démarrer à l'ouverture de session", + "start_at_login_description": "L'application démarre seule quand vous ouvrez votre session.", + "start_minimized_label": "Démarrer dans la zone de notification", + "start_minimized_description": "Aucune fenêtre ne s'ouvre à l'ouverture de session. Ouvrez-la avec l'icône de la zone de notification.", + "attention_label": "Faire clignoter la barre des tâches à l'arrivée de courrier", + "attention_mac_label": "Faire rebondir l'icône du Dock à l'arrivée de courrier", + "attention_description": "Seulement quand l'application n'est pas au premier plan.", + "quiet_hours_label": "Heures calmes", + "quiet_hours_description": "Ni notifications ni sons pendant ces heures. Le courrier continue d'arriver.", + "quiet_hours_from": "De", + "quiet_hours_to": "à", + "show_shortcut_label": "Afficher l'application avec un raccourci clavier", + "show_shortcut_description": "{keys} amène l'application au premier plan depuis n'importe quelle autre.", + "show_shortcut_taken": "Une autre application utilise {keys}. Fermez-la, ou désactivez puis réactivez cette option.", + "default_app_label": "Application de messagerie par défaut", + "default_app_description": "Ouvrir les liens de messagerie et les fichiers .eml, .ics et .vcf avec cette application.", + "default_app_is_default": "Cette application ouvre vos liens de messagerie.", + "default_app_button": "Choisir l'application par défaut…", + "new_mail": "Nouveau courrier", + "no_subject": "(sans objet)", + "action_delete": "Supprimer", + "copy_as_file": "Copier en tant que fichier", + "copied": "Fichier copié. Vous pouvez le coller dans un dossier.", + "copy_failed": "Le fichier n'a pas pu être copié.", + "reply_lost": "Votre réponse saisie n'a pas pu être conservée. Ouvrez le message et répondez depuis celui-ci." + }, "desktop_sign_in": { "browser_button": "Se connecter avec le navigateur", "browser_waiting": "Terminez la connexion dans votre navigateur", diff --git a/locales/he/common.json b/locales/he/common.json index 571f30e34..8c3d28f99 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6546,6 +6546,37 @@ "message_moved_to": "ההודעה הזו הועברה אל {folder}.", "window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן." }, + "desktopOs": { + "section_title": "יישום שולחן עבודה", + "section_description": "איך היישום עובד עם המחשב שלך.", + "close_to_tray_label": "להמשיך לפעול במגש המערכת בסגירה", + "close_to_tray_description": "דואר חדש ותזכורות ממשיכים להגיע אחרי סגירת החלון.", + "start_at_login_label": "להפעיל בכניסה למחשב", + "start_at_login_description": "היישום מופעל מעצמו כשנכנסים.", + "start_minimized_label": "להפעיל במגש המערכת", + "start_minimized_description": "בכניסה לא נפתח חלון. פותחים אותו מהסמל במגש המערכת.", + "attention_label": "הבהוב בשורת המשימות בדואר חדש", + "attention_mac_label": "קפיצת סמל ה-Dock בדואר חדש", + "attention_description": "רק כשהיישום לא בחזית.", + "quiet_hours_label": "שעות שקט", + "quiet_hours_description": "בשעות אלה אין התראות וצלילים. הדואר ממשיך להגיע.", + "quiet_hours_from": "מ-", + "quiet_hours_to": "עד", + "show_shortcut_label": "הצגת היישום בקיצור מקשים", + "show_shortcut_description": "{keys} מביא את היישום לחזית מכל יישום אחר.", + "show_shortcut_taken": "יישום אחר משתמש ב-{keys}. סגרו אותו, או כבו והפעילו מחדש את האפשרות הזו.", + "default_app_label": "יישום הדואר המוגדר כברירת מחדל", + "default_app_description": "פתיחת קישורי דואר וקובצי ‎.eml,‏ ‎.ics ו-‎.vcf ביישום זה.", + "default_app_is_default": "יישום זה פותח את קישורי הדואר שלך.", + "default_app_button": "בחירת יישום ברירת מחדל…", + "new_mail": "דואר חדש", + "no_subject": "(ללא נושא)", + "action_delete": "מחיקה", + "copy_as_file": "העתקה כקובץ", + "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.", + "copy_failed": "לא ניתן להעתיק את הקובץ.", + "reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם." + }, "desktop_sign_in": { "browser_button": "כניסה באמצעות הדפדפן", "browser_waiting": "יש להשלים את הכניסה בדפדפן", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2ab59129e..60c3ee5f3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}.", "window_not_ready": "Az új ablak nem nyílt meg időben. A piszkozat itt marad." }, + "desktopOs": { + "section_title": "Asztali alkalmazás", + "section_description": "Hogyan működik az alkalmazás a számítógépével.", + "close_to_tray_label": "Bezáráskor fusson tovább a tálcán", + "close_to_tray_description": "Az új levelek és emlékeztetők az ablak bezárása után is megérkeznek.", + "start_at_login_label": "Indítás a számítógépre való bejelentkezéskor", + "start_at_login_description": "Az alkalmazás bejelentkezéskor magától elindul.", + "start_minimized_label": "Indítás a tálcán", + "start_minimized_description": "Bejelentkezéskor nem nyílik ablak. A tálcaikonnal nyithatja meg.", + "attention_label": "Tálca villogtatása új levélnél", + "attention_mac_label": "Dock-ikon ugráltatása új levélnél", + "attention_description": "Csak ha az alkalmazás nincs előtérben.", + "quiet_hours_label": "Csendes órák", + "quiet_hours_description": "Ekkor nincs értesítés és hang. A levelek továbbra is megérkeznek.", + "quiet_hours_from": "Ettől", + "quiet_hours_to": "eddig", + "show_shortcut_label": "Az alkalmazás megjelenítése billentyűparanccsal", + "show_shortcut_description": "{keys} bármely más alkalmazásból előtérbe hozza az alkalmazást.", + "show_shortcut_taken": "Egy másik alkalmazás használja ezt: {keys}. Zárja be azt, vagy kapcsolja ki és be ezt a beállítást.", + "default_app_label": "Alapértelmezett levelezőalkalmazás", + "default_app_description": "A levélhivatkozások és a .eml, .ics és .vcf fájlok megnyitása ezzel az alkalmazással.", + "default_app_is_default": "Ez az alkalmazás nyitja meg a levélhivatkozásait.", + "default_app_button": "Alapértelmezett alkalmazás kiválasztása…", + "new_mail": "Új levél", + "no_subject": "(nincs tárgy)", + "action_delete": "Törlés", + "copy_as_file": "Másolás fájlként", + "copied": "Fájl másolva. Beillesztheti egy mappába.", + "copy_failed": "A fájlt nem sikerült másolni.", + "reply_lost": "A beírt válasz nem maradt meg. Nyissa meg az üzenetet, és válaszoljon ott." + }, "desktop_sign_in": { "browser_button": "Bejelentkezés böngészővel", "browser_waiting": "Fejezze be a bejelentkezést a böngészőben", diff --git a/locales/it/common.json b/locales/it/common.json index a861d3390..c7e482c6d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Questo messaggio è stato spostato in {folder}.", "window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui." }, + "desktopOs": { + "section_title": "App desktop", + "section_description": "Come l'app funziona con il tuo computer.", + "close_to_tray_label": "Resta nell'area di notifica alla chiusura", + "close_to_tray_description": "La nuova posta e i promemoria continuano ad arrivare dopo la chiusura della finestra.", + "start_at_login_label": "Avvia all'accesso al computer", + "start_at_login_description": "L'app si avvia da sola quando accedi.", + "start_minimized_label": "Avvia nell'area di notifica", + "start_minimized_description": "All'accesso non si apre alcuna finestra. Aprila con l'icona nell'area di notifica.", + "attention_label": "Fai lampeggiare la barra delle applicazioni con nuova posta", + "attention_mac_label": "Fai rimbalzare l'icona del Dock con nuova posta", + "attention_description": "Solo quando l'app non è in primo piano.", + "quiet_hours_label": "Ore di silenzio", + "quiet_hours_description": "Nessuna notifica o suono in queste ore. La posta continua ad arrivare.", + "quiet_hours_from": "Da", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'app con una scorciatoia da tastiera", + "show_shortcut_description": "{keys} porta l'app in primo piano da qualsiasi altra app.", + "show_shortcut_taken": "Un'altra app usa {keys}. Chiudila, oppure disattiva e riattiva questa opzione.", + "default_app_label": "App di posta predefinita", + "default_app_description": "Apri i link di posta e i file .eml, .ics e .vcf con questa app.", + "default_app_is_default": "Questa app apre i tuoi link di posta.", + "default_app_button": "Scegli app predefinita…", + "new_mail": "Nuova posta", + "no_subject": "(nessun oggetto)", + "action_delete": "Elimina", + "copy_as_file": "Copia come file", + "copied": "File copiato. Puoi incollarlo in una cartella.", + "copy_failed": "Impossibile copiare il file.", + "reply_lost": "Non è stato possibile conservare la risposta digitata. Apri il messaggio e rispondi da lì." + }, "desktop_sign_in": { "browser_button": "Accedi con il browser", "browser_waiting": "Completa l'accesso nel browser", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0606617f1..6927afe1a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "このメッセージは {folder} に移動されました。", "window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。" }, + "desktopOs": { + "section_title": "デスクトップアプリ", + "section_description": "アプリとパソコンの連携方法です。", + "close_to_tray_label": "閉じてもトレイで実行を続ける", + "close_to_tray_description": "ウィンドウを閉じても新着メールとリマインダーは届きます。", + "start_at_login_label": "パソコンへのサインイン時に起動", + "start_at_login_description": "サインインするとアプリが自動で起動します。", + "start_minimized_label": "トレイで起動", + "start_minimized_description": "サインイン時にウィンドウは開きません。トレイのアイコンから開いてください。", + "attention_label": "新着メールでタスクバーを点滅", + "attention_mac_label": "新着メールで Dock アイコンを跳ねさせる", + "attention_description": "アプリが前面にないときのみ。", + "quiet_hours_label": "おやすみ時間", + "quiet_hours_description": "この時間は通知も音も出ません。メールは届きます。", + "quiet_hours_from": "開始", + "quiet_hours_to": "終了", + "show_shortcut_label": "キーボードショートカットでアプリを表示", + "show_shortcut_description": "{keys} で、ほかのアプリからこのアプリを前面に表示します。", + "show_shortcut_taken": "別のアプリが {keys} を使っています。そのアプリを閉じるか、この設定をオフにしてからオンに戻してください。", + "default_app_label": "既定のメールアプリ", + "default_app_description": "メールのリンクと .eml、.ics、.vcf ファイルをこのアプリで開きます。", + "default_app_is_default": "メールのリンクはこのアプリで開きます。", + "default_app_button": "既定のアプリを選択…", + "new_mail": "新着メール", + "no_subject": "(件名なし)", + "action_delete": "削除", + "copy_as_file": "ファイルとしてコピー", + "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。", + "copy_failed": "ファイルをコピーできませんでした。", + "reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。" + }, "desktop_sign_in": { "browser_button": "ブラウザでサインイン", "browser_waiting": "ブラウザでサインインを完了してください", diff --git a/locales/ko/common.json b/locales/ko/common.json index 7e0daa574..ad9c104f7 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.", "window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다." }, + "desktopOs": { + "section_title": "데스크톱 앱", + "section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.", + "close_to_tray_label": "닫을 때 트레이에서 계속 실행", + "close_to_tray_description": "창을 닫아도 새 메일과 리마인더가 계속 도착합니다.", + "start_at_login_label": "컴퓨터에 로그인할 때 시작", + "start_at_login_description": "로그인하면 앱이 자동으로 시작됩니다.", + "start_minimized_label": "트레이에서 시작", + "start_minimized_description": "로그인할 때 창이 열리지 않습니다. 트레이 아이콘으로 여세요.", + "attention_label": "새 메일이 오면 작업 표시줄 깜박이기", + "attention_mac_label": "새 메일이 오면 Dock 아이콘 튀기기", + "attention_description": "앱이 앞에 있지 않을 때만 작동합니다.", + "quiet_hours_label": "방해 금지 시간", + "quiet_hours_description": "이 시간에는 알림과 소리가 없습니다. 메일은 계속 도착합니다.", + "quiet_hours_from": "시작", + "quiet_hours_to": "종료", + "show_shortcut_label": "키보드 단축키로 앱 표시", + "show_shortcut_description": "{keys} 키로 다른 앱에서도 이 앱을 앞으로 가져옵니다.", + "show_shortcut_taken": "다른 앱이 {keys} 키를 사용 중입니다. 그 앱을 닫거나 이 옵션을 껐다가 다시 켜세요.", + "default_app_label": "기본 메일 앱", + "default_app_description": "메일 링크와 .eml, .ics, .vcf 파일을 이 앱으로 엽니다.", + "default_app_is_default": "이 앱이 메일 링크를 엽니다.", + "default_app_button": "기본 앱 선택…", + "new_mail": "새 메일", + "no_subject": "(제목 없음)", + "action_delete": "삭제", + "copy_as_file": "파일로 복사", + "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.", + "copy_failed": "파일을 복사할 수 없습니다.", + "reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요." + }, "desktop_sign_in": { "browser_button": "브라우저로 로그인", "browser_waiting": "브라우저에서 로그인을 마치세요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 05939fbc0..a5295665f 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.", "window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit." }, + "desktopOs": { + "section_title": "Darbvirsmas lietotne", + "section_description": "Kā lietotne darbojas ar jūsu datoru.", + "close_to_tray_label": "Aizverot turpināt darbu teknē", + "close_to_tray_description": "Jauns pasts un atgādinājumi turpina pienākt arī pēc loga aizvēršanas.", + "start_at_login_label": "Sākt, piesakoties datorā", + "start_at_login_description": "Lietotne sākas pati, kad piesakāties.", + "start_minimized_label": "Sākt teknē", + "start_minimized_description": "Piesakoties logs neatveras. Atveriet to ar teknes ikonu.", + "attention_label": "Mirgot uzdevumjoslā, pienākot pastam", + "attention_mac_label": "Lēkāt Dock ikonai, pienākot pastam", + "attention_description": "Tikai tad, ja lietotne nav priekšplānā.", + "quiet_hours_label": "Klusuma stundas", + "quiet_hours_description": "Šajā laikā nav paziņojumu un skaņu. Pasts turpina pienākt.", + "quiet_hours_from": "No", + "quiet_hours_to": "līdz", + "show_shortcut_label": "Rādīt lietotni ar īsinājumtaustiņu", + "show_shortcut_description": "{keys} parāda lietotni priekšplānā no jebkuras citas lietotnes.", + "show_shortcut_taken": "{keys} izmanto cita lietotne. Aizveriet to vai izslēdziet un atkal ieslēdziet šo iestatījumu.", + "default_app_label": "Noklusējuma pasta lietotne", + "default_app_description": "Atvērt pasta saites un .eml, .ics un .vcf failus ar šo lietotni.", + "default_app_is_default": "Šī lietotne atver jūsu pasta saites.", + "default_app_button": "Izvēlēties noklusējuma lietotni…", + "new_mail": "Jauns pasts", + "no_subject": "(nav tēmas)", + "action_delete": "Dzēst", + "copy_as_file": "Kopēt kā failu", + "copied": "Fails nokopēts. Varat to ielīmēt mapē.", + "copy_failed": "Failu neizdevās nokopēt.", + "reply_lost": "Ievadīto atbildi neizdevās saglabāt. Atveriet ziņojumu un atbildiet tur." + }, "desktop_sign_in": { "browser_button": "Pierakstīties ar pārlūku", "browser_waiting": "Pabeidziet pierakstīšanos pārlūkā", diff --git a/locales/mn/common.json b/locales/mn/common.json index dfa942d34..7fe52244f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.", "window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ." }, + "desktopOs": { + "section_title": "Ширээний апп", + "section_description": "Апп таны компьютертэй хэрхэн ажиллах.", + "close_to_tray_label": "Хаахад тавиур дээр үргэлжлүүлэн ажиллах", + "close_to_tray_description": "Цонхыг хаасны дараа ч шинэ захиа, сануулга ирсээр байна.", + "start_at_login_label": "Компьютерт нэвтрэхэд эхлүүлэх", + "start_at_login_description": "Нэвтрэхэд апп өөрөө эхэлнэ.", + "start_minimized_label": "Тавиур дээр эхлүүлэх", + "start_minimized_description": "Нэвтрэхэд цонх нээгдэхгүй. Тавиурын дүрсээр нээнэ үү.", + "attention_label": "Шинэ захиа ирэхэд ажлын самбарыг анивчуулах", + "attention_mac_label": "Шинэ захиа ирэхэд Dock дүрсийг үсрүүлэх", + "attention_description": "Зөвхөн апп урд талд байхгүй үед.", + "quiet_hours_label": "Чимээгүй цаг", + "quiet_hours_description": "Энэ үед мэдэгдэл, дуу гарахгүй. Захиа ирсээр байна.", + "quiet_hours_from": "Эхлэх", + "quiet_hours_to": "хүртэл", + "show_shortcut_label": "Гарын товчлолоор аппыг харуулах", + "show_shortcut_description": "{keys} нь аль ч аппаас энэ аппыг урд гаргана.", + "show_shortcut_taken": "{keys}-г өөр апп ашиглаж байна. Тэр аппыг хаах, эсвэл үүнийг унтраагаад дахин асаана уу.", + "default_app_label": "Үндсэн шуудангийн апп", + "default_app_description": "Шуудангийн холбоос болон .eml, .ics, .vcf файлыг энэ аппаар нээх.", + "default_app_is_default": "Энэ апп таны шуудангийн холбоосыг нээдэг.", + "default_app_button": "Үндсэн апп сонгох…", + "new_mail": "Шинэ захиа", + "no_subject": "(гарчиггүй)", + "action_delete": "Устгах", + "copy_as_file": "Файл болгож хуулах", + "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.", + "copy_failed": "Файлыг хуулж чадсангүй.", + "reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу." + }, "desktop_sign_in": { "browser_button": "Хөтчөөр нэвтрэх", "browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу", diff --git a/locales/nb/common.json b/locales/nb/common.json index a0002acca..9dbd11a2c 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Denne meldingen ble flyttet til {folder}.", "window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her." }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Slik samarbeider appen med datamaskinen din.", + "close_to_tray_label": "Fortsett i systemstatusfeltet ved lukking", + "close_to_tray_description": "Ny e-post og påminnelser fortsetter å komme etter at du lukker vinduet.", + "start_at_login_label": "Start når du logger på datamaskinen", + "start_at_login_description": "Appen starter av seg selv når du logger på.", + "start_minimized_label": "Start i systemstatusfeltet", + "start_minimized_description": "Det åpnes ikke noe vindu ved pålogging. Åpne appen med ikonet i systemstatusfeltet.", + "attention_label": "Blink i oppgavelinjen ved ny e-post", + "attention_mac_label": "La Dock-ikonet sprette ved ny e-post", + "attention_description": "Bare når appen ikke er foran.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen varsler eller lyder i dette tidsrommet. E-post kommer fortsatt.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en hurtigtast", + "show_shortcut_description": "{keys} henter appen frem fra hvilken som helst annen app.", + "show_shortcut_taken": "En annen app bruker {keys}. Lukk den appen, eller slå dette av og på igjen.", + "default_app_label": "Standard e-postapp", + "default_app_description": "Åpne e-postlenker og .eml-, .ics- og .vcf-filer med denne appen.", + "default_app_is_default": "Denne appen åpner e-postlenkene dine.", + "default_app_button": "Velg standardapp…", + "new_mail": "Ny e-post", + "no_subject": "(uten emne)", + "action_delete": "Slett", + "copy_as_file": "Kopier som fil", + "copied": "Filen er kopiert. Du kan lime den inn i en mappe.", + "copy_failed": "Filen kunne ikke kopieres.", + "reply_lost": "Svaret du skrev, kunne ikke beholdes. Åpne meldingen og svar der." + }, "desktop_sign_in": { "browser_button": "Logg på med nettleseren", "browser_waiting": "Fullfør påloggingen i nettleseren", diff --git a/locales/nl/common.json b/locales/nl/common.json index 5524a36da..ca060609f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Dit bericht is verplaatst naar {folder}.", "window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier." }, + "desktopOs": { + "section_title": "Desktop-app", + "section_description": "Hoe de app samenwerkt met je computer.", + "close_to_tray_label": "Bij sluiten doorgaan in het systeemvak", + "close_to_tray_description": "Nieuwe mail en herinneringen blijven komen nadat je het venster sluit.", + "start_at_login_label": "Starten als je je aanmeldt op de computer", + "start_at_login_description": "De app start vanzelf als je je aanmeldt.", + "start_minimized_label": "Starten in het systeemvak", + "start_minimized_description": "Bij het aanmelden opent geen venster. Open de app met het pictogram in het systeemvak.", + "attention_label": "Taakbalk laten knipperen bij nieuwe mail", + "attention_mac_label": "Dock-pictogram laten stuiteren bij nieuwe mail", + "attention_description": "Alleen als de app niet vooraan staat.", + "quiet_hours_label": "Stille uren", + "quiet_hours_description": "Geen meldingen of geluiden op deze tijden. Mail blijft binnenkomen.", + "quiet_hours_from": "Van", + "quiet_hours_to": "tot", + "show_shortcut_label": "App tonen met een sneltoets", + "show_shortcut_description": "{keys} haalt de app naar voren vanuit elke andere app.", + "show_shortcut_taken": "Een andere app gebruikt {keys}. Sluit die app, of zet dit uit en weer aan.", + "default_app_label": "Standaard-mailapp", + "default_app_description": "Maillinks en .eml-, .ics- en .vcf-bestanden openen met deze app.", + "default_app_is_default": "Deze app opent je maillinks.", + "default_app_button": "Standaard-app kiezen…", + "new_mail": "Nieuwe mail", + "no_subject": "(geen onderwerp)", + "action_delete": "Verwijderen", + "copy_as_file": "Kopiëren als bestand", + "copied": "Bestand gekopieerd. Je kunt het in een map plakken.", + "copy_failed": "Het bestand kon niet worden gekopieerd.", + "reply_lost": "Je getypte antwoord kon niet worden bewaard. Open het bericht en antwoord daar." + }, "desktop_sign_in": { "browser_button": "Inloggen met je browser", "browser_waiting": "Rond het inloggen af in je browser", diff --git a/locales/pl/common.json b/locales/pl/common.json index fa5b5481e..155963c34 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Ta wiadomość została przeniesiona do {folder}.", "window_not_ready": "Nowe okno nie otworzyło się na czas. Twój szkic zostaje tutaj." }, + "desktopOs": { + "section_title": "Aplikacja na komputer", + "section_description": "Jak aplikacja współpracuje z Twoim komputerem.", + "close_to_tray_label": "Po zamknięciu działaj dalej w zasobniku", + "close_to_tray_description": "Nowa poczta i przypomnienia docierają też po zamknięciu okna.", + "start_at_login_label": "Uruchamiaj po zalogowaniu do komputera", + "start_at_login_description": "Aplikacja uruchamia się sama po zalogowaniu.", + "start_minimized_label": "Uruchamiaj w zasobniku", + "start_minimized_description": "Po zalogowaniu nie otwiera się okno. Otwórz je ikoną w zasobniku.", + "attention_label": "Migaj na pasku zadań przy nowej poczcie", + "attention_mac_label": "Podskakuj ikoną w Docku przy nowej poczcie", + "attention_description": "Tylko gdy aplikacja nie jest na wierzchu.", + "quiet_hours_label": "Godziny ciszy", + "quiet_hours_description": "W tym czasie bez powiadomień i dźwięków. Poczta nadal dociera.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Pokazuj aplikację skrótem klawiszowym", + "show_shortcut_description": "{keys} przywołuje aplikację z każdej innej aplikacji.", + "show_shortcut_taken": "Skrót {keys} jest używany przez inną aplikację. Zamknij ją albo wyłącz i włącz tę opcję.", + "default_app_label": "Domyślna aplikacja poczty", + "default_app_description": "Otwieraj linki pocztowe i pliki .eml, .ics i .vcf w tej aplikacji.", + "default_app_is_default": "Ta aplikacja otwiera Twoje linki pocztowe.", + "default_app_button": "Wybierz domyślną aplikację…", + "new_mail": "Nowa poczta", + "no_subject": "(bez tematu)", + "action_delete": "Usuń", + "copy_as_file": "Kopiuj jako plik", + "copied": "Plik skopiowany. Możesz wkleić go do folderu.", + "copy_failed": "Nie udało się skopiować pliku.", + "reply_lost": "Nie udało się zachować wpisanej odpowiedzi. Otwórz wiadomość i odpowiedz w niej." + }, "desktop_sign_in": { "browser_button": "Zaloguj się w przeglądarce", "browser_waiting": "Dokończ logowanie w przeglądarce", diff --git a/locales/pt/common.json b/locales/pt/common.json index 87a2e2ff1..9940ba25c 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Esta mensagem foi movida para {folder}.", "window_not_ready": "A nova janela não abriu a tempo. O seu rascunho fica aqui." }, + "desktopOs": { + "section_title": "Aplicação de ambiente de trabalho", + "section_description": "Como a aplicação funciona com o seu computador.", + "close_to_tray_label": "Continuar na bandeja ao fechar", + "close_to_tray_description": "O correio novo e os lembretes continuam a chegar depois de fechar a janela.", + "start_at_login_label": "Iniciar ao entrar no computador", + "start_at_login_description": "A aplicação inicia sozinha quando entra.", + "start_minimized_label": "Iniciar na bandeja", + "start_minimized_description": "Não abre nenhuma janela ao entrar. Abra-a com o ícone da bandeja.", + "attention_label": "Piscar a barra de tarefas com correio novo", + "attention_mac_label": "Saltar o ícone da Dock com correio novo", + "attention_description": "Só quando a aplicação não está à frente.", + "quiet_hours_label": "Horas de silêncio", + "quiet_hours_description": "Sem notificações nem sons nestas horas. O correio continua a chegar.", + "quiet_hours_from": "De", + "quiet_hours_to": "até", + "show_shortcut_label": "Mostrar a aplicação com um atalho de teclado", + "show_shortcut_description": "{keys} traz a aplicação para a frente a partir de qualquer outra.", + "show_shortcut_taken": "Outra aplicação está a usar {keys}. Feche-a, ou desative e volte a ativar esta opção.", + "default_app_label": "Aplicação de correio predefinida", + "default_app_description": "Abrir ligações de correio e ficheiros .eml, .ics e .vcf com esta aplicação.", + "default_app_is_default": "Esta aplicação abre as suas ligações de correio.", + "default_app_button": "Escolher aplicação predefinida…", + "new_mail": "Correio novo", + "no_subject": "(sem assunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como ficheiro", + "copied": "Ficheiro copiado. Pode colá-lo numa pasta.", + "copy_failed": "Não foi possível copiar o ficheiro.", + "reply_lost": "Não foi possível guardar a resposta escrita. Abra a mensagem e responda a partir dela." + }, "desktop_sign_in": { "browser_button": "Entrar com o navegador", "browser_waiting": "Termine de entrar no navegador", diff --git a/locales/ro/common.json b/locales/ro/common.json index 80c31fa28..b12f0f293 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Acest mesaj a fost mutat în {folder}.", "window_not_ready": "Fereastra nouă nu s-a deschis la timp. Ciorna rămâne aici." }, + "desktopOs": { + "section_title": "Aplicație desktop", + "section_description": "Cum funcționează aplicația cu computerul tău.", + "close_to_tray_label": "Rămâne în zona de notificări la închidere", + "close_to_tray_description": "E-mailurile noi și mementourile continuă să sosească după închiderea ferestrei.", + "start_at_login_label": "Pornește la conectarea în computer", + "start_at_login_description": "Aplicația pornește singură când te conectezi.", + "start_minimized_label": "Pornește în zona de notificări", + "start_minimized_description": "La conectare nu se deschide nicio fereastră. Deschide-o din pictograma din zona de notificări.", + "attention_label": "Clipește în bara de activități la e-mail nou", + "attention_mac_label": "Sare pictograma din Dock la e-mail nou", + "attention_description": "Doar când aplicația nu este în față.", + "quiet_hours_label": "Ore de liniște", + "quiet_hours_description": "Fără notificări sau sunete în aceste ore. E-mailurile sosesc în continuare.", + "quiet_hours_from": "De la", + "quiet_hours_to": "până la", + "show_shortcut_label": "Arată aplicația cu o comandă rapidă de la tastatură", + "show_shortcut_description": "{keys} aduce aplicația în față din orice altă aplicație.", + "show_shortcut_taken": "Altă aplicație folosește {keys}. Închide-o sau dezactivează și reactivează această opțiune.", + "default_app_label": "Aplicația de e-mail implicită", + "default_app_description": "Deschide linkurile de e-mail și fișierele .eml, .ics și .vcf cu această aplicație.", + "default_app_is_default": "Această aplicație îți deschide linkurile de e-mail.", + "default_app_button": "Alege aplicația implicită…", + "new_mail": "E-mail nou", + "no_subject": "(fără subiect)", + "action_delete": "Șterge", + "copy_as_file": "Copiază ca fișier", + "copied": "Fișier copiat. Îl poți lipi într-un dosar.", + "copy_failed": "Fișierul nu a putut fi copiat.", + "reply_lost": "Răspunsul scris nu a putut fi păstrat. Deschide mesajul și răspunde de acolo." + }, "desktop_sign_in": { "browser_button": "Conectează-te cu browserul", "browser_waiting": "Finalizează conectarea în browser", diff --git a/locales/ru/common.json b/locales/ru/common.json index 55510f4c8..2679de84a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Это письмо перемещено в {folder}.", "window_not_ready": "Новое окно не открылось вовремя. Черновик остаётся здесь." }, + "desktopOs": { + "section_title": "Настольное приложение", + "section_description": "Как приложение работает с вашим компьютером.", + "close_to_tray_label": "При закрытии продолжать работу в трее", + "close_to_tray_description": "Новая почта и напоминания приходят и после закрытия окна.", + "start_at_login_label": "Запускать при входе в систему", + "start_at_login_description": "Приложение запускается само, когда вы входите.", + "start_minimized_label": "Запускать в трее", + "start_minimized_description": "При входе окно не открывается. Откройте его значком в трее.", + "attention_label": "Мигать на панели задач при новой почте", + "attention_mac_label": "Подпрыгивать значком в Dock при новой почте", + "attention_description": "Только когда приложение не на переднем плане.", + "quiet_hours_label": "Тихие часы", + "quiet_hours_description": "В это время нет уведомлений и звуков. Почта продолжает приходить.", + "quiet_hours_from": "С", + "quiet_hours_to": "до", + "show_shortcut_label": "Показывать приложение сочетанием клавиш", + "show_shortcut_description": "{keys} выводит приложение на передний план из любого другого.", + "show_shortcut_taken": "{keys} использует другое приложение. Закройте его или выключите и снова включите этот параметр.", + "default_app_label": "Почтовое приложение по умолчанию", + "default_app_description": "Открывать почтовые ссылки и файлы .eml, .ics и .vcf в этом приложении.", + "default_app_is_default": "Это приложение открывает ваши почтовые ссылки.", + "default_app_button": "Выбрать приложение по умолчанию…", + "new_mail": "Новая почта", + "no_subject": "(без темы)", + "action_delete": "Удалить", + "copy_as_file": "Копировать как файл", + "copied": "Файл скопирован. Его можно вставить в папку.", + "copy_failed": "Не удалось скопировать файл.", + "reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него." + }, "desktop_sign_in": { "browser_button": "Войти через браузер", "browser_waiting": "Завершите вход в браузере", diff --git a/locales/sk/common.json b/locales/sk/common.json index 9e12b12d2..8d23419cf 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Táto správa bola presunutá do {folder}.", "window_not_ready": "Nové okno sa neotvorilo včas. Koncept zostáva tu." }, + "desktopOs": { + "section_title": "Počítačová aplikácia", + "section_description": "Ako aplikácia spolupracuje s vaším počítačom.", + "close_to_tray_label": "Po zatvorení bežať ďalej v oblasti oznámení", + "close_to_tray_description": "Nová pošta a pripomienky prichádzajú aj po zatvorení okna.", + "start_at_login_label": "Spustiť po prihlásení do počítača", + "start_at_login_description": "Aplikácia sa po prihlásení spustí sama.", + "start_minimized_label": "Spustiť v oblasti oznámení", + "start_minimized_description": "Po prihlásení sa neotvorí žiadne okno. Otvorte ju ikonou v oblasti oznámení.", + "attention_label": "Pri novej pošte blikať na paneli úloh", + "attention_mac_label": "Pri novej pošte poskakovať ikonou v Docku", + "attention_description": "Len keď aplikácia nie je v popredí.", + "quiet_hours_label": "Čas pokoja", + "quiet_hours_description": "V tomto čase žiadne oznámenia ani zvuky. Pošta prichádza ďalej.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobraziť aplikáciu klávesovou skratkou", + "show_shortcut_description": "{keys} prenesie aplikáciu do popredia z akejkoľvek inej aplikácie.", + "show_shortcut_taken": "{keys} používa iná aplikácia. Zatvorte ju alebo túto možnosť vypnite a znova zapnite.", + "default_app_label": "Predvolená poštová aplikácia", + "default_app_description": "Otvárať poštové odkazy a súbory .eml, .ics a .vcf v tejto aplikácii.", + "default_app_is_default": "Táto aplikácia otvára vaše poštové odkazy.", + "default_app_button": "Vybrať predvolenú aplikáciu…", + "new_mail": "Nová pošta", + "no_subject": "(bez predmetu)", + "action_delete": "Odstrániť", + "copy_as_file": "Kopírovať ako súbor", + "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.", + "copy_failed": "Súbor sa nepodarilo skopírovať.", + "reply_lost": "Napísanú odpoveď sa nepodarilo zachovať. Otvorte správu a odpovedzte v nej." + }, "desktop_sign_in": { "browser_button": "Prihlásiť sa v prehliadači", "browser_waiting": "Dokončite prihlásenie v prehliadači", diff --git a/locales/th/common.json b/locales/th/common.json index 0be3db9db..0f81a68cb 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6553,6 +6553,37 @@ "message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}", "window_not_ready": "หน้าต่างใหม่เปิดไม่ทัน แบบร่างของคุณยังอยู่ที่นี่" }, + "desktopOs": { + "section_title": "แอปเดสก์ท็อป", + "section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ", + "close_to_tray_label": "ทำงานต่อในถาดระบบเมื่อปิด", + "close_to_tray_description": "เมลใหม่และการแจ้งเตือนยังเข้ามาหลังปิดหน้าต่าง", + "start_at_login_label": "เริ่มเมื่อลงชื่อเข้าใช้คอมพิวเตอร์", + "start_at_login_description": "แอปเริ่มเองเมื่อคุณลงชื่อเข้าใช้", + "start_minimized_label": "เริ่มในถาดระบบ", + "start_minimized_description": "ไม่มีหน้าต่างเปิดเมื่อลงชื่อเข้าใช้ เปิดด้วยไอคอนในถาดระบบ", + "attention_label": "กะพริบแถบงานเมื่อมีเมลใหม่", + "attention_mac_label": "ให้ไอคอน Dock เด้งเมื่อมีเมลใหม่", + "attention_description": "เฉพาะเมื่อแอปไม่ได้อยู่ด้านหน้า", + "quiet_hours_label": "ช่วงเวลาเงียบ", + "quiet_hours_description": "ไม่มีการแจ้งเตือนและเสียงในช่วงนี้ เมลยังเข้ามาตามปกติ", + "quiet_hours_from": "จาก", + "quiet_hours_to": "ถึง", + "show_shortcut_label": "แสดงแอปด้วยแป้นพิมพ์ลัด", + "show_shortcut_description": "{keys} นำแอปขึ้นมาด้านหน้าจากแอปอื่นใดก็ได้", + "show_shortcut_taken": "แอปอื่นใช้ {keys} อยู่ ปิดแอปนั้น หรือปิดแล้วเปิดตัวเลือกนี้อีกครั้ง", + "default_app_label": "แอปเมลเริ่มต้น", + "default_app_description": "เปิดลิงก์เมลและไฟล์ .eml, .ics และ .vcf ด้วยแอปนี้", + "default_app_is_default": "แอปนี้เปิดลิงก์เมลของคุณ", + "default_app_button": "เลือกแอปเริ่มต้น…", + "new_mail": "เมลใหม่", + "no_subject": "(ไม่มีหัวเรื่อง)", + "action_delete": "ลบ", + "copy_as_file": "คัดลอกเป็นไฟล์", + "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้", + "copy_failed": "คัดลอกไฟล์ไม่ได้", + "reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น" + }, "desktop_sign_in": { "browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์", "browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์", diff --git a/locales/tr/common.json b/locales/tr/common.json index fa74d9864..a9e5517e7 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Bu ileti {folder} klasörüne taşındı.", "window_not_ready": "Yeni pencere zamanında açılmadı. Taslağınız burada kalıyor." }, + "desktopOs": { + "section_title": "Masaüstü uygulaması", + "section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.", + "close_to_tray_label": "Kapatınca sistem tepsisinde çalışmaya devam et", + "close_to_tray_description": "Pencereyi kapattıktan sonra da yeni postalar ve anımsatıcılar gelmeye devam eder.", + "start_at_login_label": "Bilgisayarda oturum açınca başlat", + "start_at_login_description": "Oturum açtığınızda uygulama kendiliğinden başlar.", + "start_minimized_label": "Sistem tepsisinde başlat", + "start_minimized_description": "Oturum açarken pencere açılmaz. Sistem tepsisindeki simgeyle açın.", + "attention_label": "Yeni postada görev çubuğunu yanıp söndür", + "attention_mac_label": "Yeni postada Dock simgesini zıplat", + "attention_description": "Yalnızca uygulama önde değilken.", + "quiet_hours_label": "Sessiz saatler", + "quiet_hours_description": "Bu saatlerde bildirim ve ses yok. Postalar gelmeye devam eder.", + "quiet_hours_from": "Başlangıç", + "quiet_hours_to": "bitiş", + "show_shortcut_label": "Uygulamayı klavye kısayoluyla göster", + "show_shortcut_description": "{keys} uygulamayı başka herhangi bir uygulamadan öne getirir.", + "show_shortcut_taken": "{keys} başka bir uygulama tarafından kullanılıyor. O uygulamayı kapatın ya da bunu kapatıp yeniden açın.", + "default_app_label": "Varsayılan posta uygulaması", + "default_app_description": "Posta bağlantılarını ve .eml, .ics, .vcf dosyalarını bu uygulamayla aç.", + "default_app_is_default": "Posta bağlantılarınızı bu uygulama açıyor.", + "default_app_button": "Varsayılan uygulamayı seç…", + "new_mail": "Yeni posta", + "no_subject": "(konu yok)", + "action_delete": "Sil", + "copy_as_file": "Dosya olarak kopyala", + "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.", + "copy_failed": "Dosya kopyalanamadı.", + "reply_lost": "Yazdığınız yanıt korunamadı. İletiyi açıp oradan yanıtlayın." + }, "desktop_sign_in": { "browser_button": "Tarayıcıyla oturum aç", "browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın", diff --git a/locales/uk/common.json b/locales/uk/common.json index 391a5c203..f393aaf5a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "Цього листа переміщено в {folder}.", "window_not_ready": "Нове вікно не відкрилося вчасно. Чернетка залишається тут." }, + "desktopOs": { + "section_title": "Настільний застосунок", + "section_description": "Як застосунок працює з вашим комп’ютером.", + "close_to_tray_label": "Під час закриття працювати далі в треї", + "close_to_tray_description": "Нова пошта й нагадування надходять і після закриття вікна.", + "start_at_login_label": "Запускати під час входу в систему", + "start_at_login_description": "Застосунок запускається сам, коли ви входите.", + "start_minimized_label": "Запускати в треї", + "start_minimized_description": "Під час входу вікно не відкривається. Відкрийте його значком у треї.", + "attention_label": "Блимати на панелі завдань за нової пошти", + "attention_mac_label": "Підстрибувати значком у Dock за нової пошти", + "attention_description": "Лише коли застосунок не на передньому плані.", + "quiet_hours_label": "Тихі години", + "quiet_hours_description": "У цей час немає сповіщень і звуків. Пошта надходить далі.", + "quiet_hours_from": "З", + "quiet_hours_to": "до", + "show_shortcut_label": "Показувати застосунок сполученням клавіш", + "show_shortcut_description": "{keys} виводить застосунок на передній план із будь-якого іншого.", + "show_shortcut_taken": "{keys} використовує інший застосунок. Закрийте його або вимкніть і знову увімкніть цей параметр.", + "default_app_label": "Поштовий застосунок за замовчуванням", + "default_app_description": "Відкривати поштові посилання та файли .eml, .ics і .vcf у цьому застосунку.", + "default_app_is_default": "Цей застосунок відкриває ваші поштові посилання.", + "default_app_button": "Вибрати застосунок за замовчуванням…", + "new_mail": "Нова пошта", + "no_subject": "(без теми)", + "action_delete": "Видалити", + "copy_as_file": "Копіювати як файл", + "copied": "Файл скопійовано. Його можна вставити в папку.", + "copy_failed": "Не вдалося скопіювати файл.", + "reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому." + }, "desktop_sign_in": { "browser_button": "Увійти через браузер", "browser_waiting": "Завершіть вхід у браузері", diff --git a/locales/vi/common.json b/locales/vi/common.json index 347fe2cd0..4d83b90c8 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6557,6 +6557,37 @@ "message_moved_to": "Thư này đã được chuyển đến {folder}.", "window_not_ready": "Cửa sổ mới không mở kịp. Bản nháp của bạn vẫn ở đây." }, + "desktopOs": { + "section_title": "Ứng dụng máy tính", + "section_description": "Cách ứng dụng hoạt động với máy tính của bạn.", + "close_to_tray_label": "Tiếp tục chạy ở khay hệ thống khi đóng", + "close_to_tray_description": "Thư mới và lời nhắc vẫn đến sau khi bạn đóng cửa sổ.", + "start_at_login_label": "Khởi động khi đăng nhập vào máy tính", + "start_at_login_description": "Ứng dụng tự khởi động khi bạn đăng nhập.", + "start_minimized_label": "Khởi động ở khay hệ thống", + "start_minimized_description": "Không có cửa sổ nào mở khi đăng nhập. Mở bằng biểu tượng ở khay hệ thống.", + "attention_label": "Nhấp nháy thanh tác vụ khi có thư mới", + "attention_mac_label": "Làm biểu tượng Dock nảy khi có thư mới", + "attention_description": "Chỉ khi ứng dụng không ở phía trước.", + "quiet_hours_label": "Giờ yên tĩnh", + "quiet_hours_description": "Không có thông báo hay âm thanh vào giờ này. Thư vẫn đến.", + "quiet_hours_from": "Từ", + "quiet_hours_to": "đến", + "show_shortcut_label": "Hiện ứng dụng bằng phím tắt", + "show_shortcut_description": "{keys} đưa ứng dụng lên trước từ bất kỳ ứng dụng nào khác.", + "show_shortcut_taken": "Ứng dụng khác đang dùng {keys}. Hãy đóng ứng dụng đó, hoặc tắt rồi bật lại tùy chọn này.", + "default_app_label": "Ứng dụng thư mặc định", + "default_app_description": "Mở liên kết thư và tệp .eml, .ics, .vcf bằng ứng dụng này.", + "default_app_is_default": "Ứng dụng này mở các liên kết thư của bạn.", + "default_app_button": "Chọn ứng dụng mặc định…", + "new_mail": "Thư mới", + "no_subject": "(không có chủ đề)", + "action_delete": "Xóa", + "copy_as_file": "Sao chép dưới dạng tệp", + "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.", + "copy_failed": "Không thể sao chép tệp.", + "reply_lost": "Không thể giữ câu trả lời bạn đã nhập. Hãy mở thư và trả lời ở đó." + }, "desktop_sign_in": { "browser_button": "Đăng nhập bằng trình duyệt", "browser_waiting": "Hoàn tất đăng nhập trong trình duyệt", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 97dacbb06..ecd9e0cce 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "這封郵件已移到 {folder}。", "window_not_ready": "新視窗未能及時開啟。你的草稿仍保留在這裡。" }, + "desktopOs": { + "section_title": "桌面應用程式", + "section_description": "應用程式如何與您的電腦配合。", + "close_to_tray_label": "關閉時繼續在系統匣中執行", + "close_to_tray_description": "關閉視窗後,新郵件和提醒仍會送達。", + "start_at_login_label": "登入電腦時啟動", + "start_at_login_description": "您登入時應用程式會自動啟動。", + "start_minimized_label": "在系統匣中啟動", + "start_minimized_description": "登入時不開啟視窗。請透過系統匣圖示開啟。", + "attention_label": "有新郵件時閃爍工作列", + "attention_mac_label": "有新郵件時讓 Dock 圖示跳動", + "attention_description": "僅在應用程式不在前景時。", + "quiet_hours_label": "勿擾時段", + "quiet_hours_description": "此時段沒有通知和聲音。郵件仍會送達。", + "quiet_hours_from": "從", + "quiet_hours_to": "到", + "show_shortcut_label": "使用鍵盤快速鍵顯示應用程式", + "show_shortcut_description": "{keys} 可從任何其他應用程式將此應用程式帶到前景。", + "show_shortcut_taken": "其他應用程式正在使用 {keys}。請關閉該應用程式,或關閉此選項後再開啟。", + "default_app_label": "預設郵件應用程式", + "default_app_description": "使用此應用程式開啟郵件連結以及 .eml、.ics 和 .vcf 檔案。", + "default_app_is_default": "此應用程式會開啟您的郵件連結。", + "default_app_button": "選擇預設應用程式…", + "new_mail": "新郵件", + "no_subject": "(無主旨)", + "action_delete": "刪除", + "copy_as_file": "複製為檔案", + "copied": "檔案已複製。您可以將其貼到資料夾中。", + "copy_failed": "無法複製檔案。", + "reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。" + }, "desktop_sign_in": { "browser_button": "使用瀏覽器登入", "browser_waiting": "請在瀏覽器中完成登入", diff --git a/locales/zh/common.json b/locales/zh/common.json index de9e40f3c..5650920a2 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6545,6 +6545,37 @@ "message_moved_to": "这封邮件已移动到 {folder}。", "window_not_ready": "新窗口未能及时打开。你的草稿仍保留在这里。" }, + "desktopOs": { + "section_title": "桌面应用", + "section_description": "应用如何与你的电脑配合。", + "close_to_tray_label": "关闭时继续在托盘中运行", + "close_to_tray_description": "关闭窗口后,新邮件和提醒仍会到达。", + "start_at_login_label": "登录电脑时启动", + "start_at_login_description": "你登录时应用会自动启动。", + "start_minimized_label": "在托盘中启动", + "start_minimized_description": "登录时不打开窗口。请通过托盘图标打开。", + "attention_label": "有新邮件时闪烁任务栏", + "attention_mac_label": "有新邮件时让 Dock 图标跳动", + "attention_description": "仅在应用不在前台时。", + "quiet_hours_label": "免打扰时段", + "quiet_hours_description": "此时段没有通知和声音。邮件仍会到达。", + "quiet_hours_from": "从", + "quiet_hours_to": "到", + "show_shortcut_label": "使用键盘快捷键显示应用", + "show_shortcut_description": "{keys} 可从任何其他应用把此应用带到前台。", + "show_shortcut_taken": "其他应用正在使用 {keys}。请关闭该应用,或关闭此选项后重新打开。", + "default_app_label": "默认邮件应用", + "default_app_description": "用此应用打开邮件链接以及 .eml、.ics 和 .vcf 文件。", + "default_app_is_default": "此应用会打开你的邮件链接。", + "default_app_button": "选择默认应用…", + "new_mail": "新邮件", + "no_subject": "(无主题)", + "action_delete": "删除", + "copy_as_file": "复制为文件", + "copied": "文件已复制。你可以将其粘贴到文件夹中。", + "copy_failed": "无法复制文件。", + "reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。" + }, "desktop_sign_in": { "browser_button": "使用浏览器登录", "browser_waiting": "请在浏览器中完成登录", From 4fd5c8c1aa3f054a900217f3fe38716362588ec1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:25:50 +0100 Subject: [PATCH 10/45] feat: keep a record of calendar reminders in the local store, so each fires once and missed ones can be summed up --- lib/store/__tests__/reminders.test.ts | 393 ++++++++++++++++++++++++++ lib/store/areas.ts | 3 +- lib/store/calendar/reminders.ts | 38 ++- lib/store/reminders/index.ts | 20 ++ lib/store/reminders/ledger.ts | 229 +++++++++++++++ lib/store/reminders/schema.ts | 20 ++ 6 files changed, 699 insertions(+), 4 deletions(-) create mode 100644 lib/store/__tests__/reminders.test.ts create mode 100644 lib/store/reminders/index.ts create mode 100644 lib/store/reminders/ledger.ts create mode 100644 lib/store/reminders/schema.ts diff --git a/lib/store/__tests__/reminders.test.ts b/lib/store/__tests__/reminders.test.ts new file mode 100644 index 000000000..16cf60d2f --- /dev/null +++ b/lib/store/__tests__/reminders.test.ts @@ -0,0 +1,393 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE } from '../area'; +import { calendarArea } from '../calendar'; +import { AccountEngine } from '../engine/account-engine'; +import { remindersArea } from '../reminders'; +import { FRESH_MS, MISSED_REACH_MS, type MissedBatch, type TickResult } from '../reminders/ledger'; +import type { StorageAdapter } from '../storage'; +import { CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { openMemoryStore } from './helpers/node-adapter'; + +const MIN = 60_000; +/** 10:00 in Berlin on 2026-11-10, a Tuesday. The events' alert goes 15 minutes before. */ +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 WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; +const PARAMS = { timeZone: 'Europe/Berlin', tasks: true }; + +interface Rig { + server: FakeCalendarJmap; + db: StorageAdapter; + engine: AccountEngine; + now: number; + /** What a tab, a window or the next start of the app asks. */ + tick(params?: Partial & { muted?: boolean }): Promise; + call(name: string, params?: unknown): Promise; + restart(): void; +} + +function makeEngine(rig: Pick): AccountEngine { + return new AccountEngine({ + account: 'a@fake.test', + db: rig.db, + session: CALENDAR_SESSION, + transport: rig.server, + areas: [calendarArea, remindersArea], + scope: DEFAULT_SCOPE, + now: () => rig.now, + sleep: async () => {}, + }); +} + +function setup(version: StalwartVersion = '1.0', startAt = FIRE - 60 * MIN): Rig { + const rig = { + server: new FakeCalendarJmap(version), + db: openMemoryStore(), + now: startAt, + } as Rig; + rig.engine = makeEngine(rig); + rig.call = (name: string, params?: unknown) => rig.engine.areaCall(name, params) as Promise; + rig.tick = (params = {}) => rig.call('reminders.tick', { ...PARAMS, ...params }); + rig.restart = () => { + rig.engine = makeEngine(rig); + }; + return rig; +} + +/** Synced, with a first check done, so the store has been watching since before the alert. */ +async function synced(rig: Rig): Promise { + await rig.engine.requestSync(); + await rig.tick(); + return rig; +} + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +/** Comes back every minute or so, with no gap that would look like being away. */ +async function walkTo(rig: Rig, to: number): Promise { + const due: TickResult['due'] = []; + while (rig.now < to) { + rig.now = Math.min(rig.now + MIN, to); + due.push(...(await rig.tick()).due); + } + return due; +} + +describe('firing', () => { + it('fires from what is stored with the network gone', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.engine.setOnline(false); + rig.server.request = (() => { + throw new Error('Nothing may reach the server for a reminder'); + }) as unknown as FakeCalendarJmap['request']; + const due = await walkTo(rig, FIRE); + expect(due.map((alert) => [alert.title, alert.fireTimeMs, alert.startMs])).toEqual([['Standup', FIRE, START]]); + }); + + it('does not fire early, and says when to ask next', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - 90_000; + const before = await rig.tick(); + expect(before.due).toEqual([]); + expect(before.nextAt).toBe(FIRE); + }); + + it('gives an alert to one caller only, across tabs and restarts', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE; + const [first, second] = await Promise.all([rig.tick(), rig.tick()]); + expect([first.due.length, second.due.length]).toEqual([1, 0]); + rig.restart(); + rig.now = FIRE + MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('expands a series itself: every occurrence fires, under its own key', async () => { + for (const version of ['1.0', '0.16'] as const) { + const rig = setup(version); + rig.server.addEvent(event({ start: '2026-10-06T10:00:00', recurrenceRule: WEEKLY })); + await synced(rig); + const first = await walkTo(rig, FIRE); + const week = 7 * 24 * 60 * MIN; + rig.now = FIRE + week - MIN; + await rig.tick(); + await rig.tick(); + rig.now = FIRE + week; + const second = (await rig.tick()).due; + expect(first.map((alert) => alert.fireTimeMs)).toEqual([FIRE]); + expect(second.map((alert) => alert.fireTimeMs)).toEqual([FIRE + week]); + expect(second[0].baseEventId).toBe('e1'); + expect(second[0].key).not.toBe(first[0].key); + expect(second[0].recurrenceId).toBeTruthy(); + } + }); + + it('keeps one key when a series gets an override, so nothing fires twice', async () => { + const rig = setup(); + rig.server.addEvent(event({ start: '2026-10-06T10:00:00', recurrenceRule: WEEKLY })); + await synced(rig); + const [before] = await walkTo(rig, FIRE); + // Another occurrence changes. On a server with synthetic ids this reshuffles them. + rig.server.patchEvent('e1', { recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'Other' } } }); + expect(before.key).toBe(`${before.jmap}:uid-1:a:${FIRE}`); + await rig.engine.requestSync(); + rig.now = FIRE + MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('reads `acknowledged` on a series as covering what fired up to then', async () => { + const rig = setup(); + const week = 7 * 24 * 60 * MIN; + rig.server.addEvent(event({ + start: '2026-10-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, acknowledged: new Date(FIRE + 60_000).toISOString() } }, + })); + await synced(rig); + expect(await walkTo(rig, FIRE + 2 * MIN)).toEqual([]); + rig.now = FIRE + week - MIN; + await rig.tick(); + await rig.tick(); + rig.now = FIRE + week; + expect((await rig.tick()).due.map((alert) => alert.fireTimeMs)).toEqual([FIRE + week]); + }); + + it('uses the calendar defaults while the event does, and leaves tasks out when asked', async () => { + const rig = setup('0.16'); + rig.server.calendars.get('cal1')!.defaultAlertsWithTime = { d1: ALERT }; + rig.server.addEvent(event({ id: 'def', uid: 'def', useDefaultAlerts: true, alerts: null })); + rig.server.addEvent({ id: 'task', uid: 'task', '@type': 'Task', title: 'Pay', start: undefined, due: '2026-11-10T09:00:00Z', alerts: { a: ALERT } }); + await rig.engine.requestSync(); + rig.now = FIRE - MIN; + await rig.tick({ tasks: false }); + rig.now = FIRE; + const due = (await rig.tick({ tasks: false })).due; + expect(due.map((alert) => [alert.baseEventId, alert.alertId])).toEqual([['def', 'd1']]); + }); + + it('holds a join link only for an event that has one', async () => { + const rig = setup(); + rig.server.addEvent(event({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } })); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Lunch', locations: { l: { '@type': 'Location', name: 'Cafe' } } })); + await synced(rig); + const due = await walkTo(rig, FIRE); + expect(Object.fromEntries(due.map((alert) => [alert.title, alert.joinUrl]))).toEqual({ + Standup: 'https://meet.jit.si/standup', + Lunch: null, + }); + }); +}); + +describe('away', () => { + async function awayWithThree(): Promise { + const rig = setup(); + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review', start: '2026-11-10T11:00:00' })); + rig.server.addEvent(event({ id: 'e3', uid: 'uid-3', title: 'Lunch', start: '2026-11-10T12:00:00' })); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + return rig; + } + + it('sends what came due while away to one summary, not a burst, and only once', async () => { + const rig = await awayWithThree(); + rig.restart(); + rig.now = FIRE + 3 * 60 * MIN; + const back = await rig.tick(); + expect(back.due).toEqual([]); + expect(back.missed).toBe(3); + const [one, two] = await Promise.all([ + rig.call('reminders.takeMissed', PARAMS), + rig.call('reminders.takeMissed', PARAMS), + ]); + expect(one!.alerts.map((alert) => alert.title)).toEqual(['Standup', 'Review', 'Lunch']); + expect(two).toBeNull(); + rig.restart(); + rig.now += MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + expect(await rig.call('reminders.takeMissed', PARAMS)).toBeNull(); + }); + + it('summarises the ones within ten minutes as well: a wake-up is no time for a burst', async () => { + const rig = await awayWithThree(); + rig.now = START + 2 * 60 * MIN - 15 * MIN + 2 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 3 }); + }); + + it('shows a single alert that just came due as a normal reminder', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE + 5 * MIN; + expect(await rig.tick()).toMatchObject({ due: [{ title: 'Standup' }], missed: 0 }); + }); + + it('summarises a single alert that is long past', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE + FRESH_MS + MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 1 }); + }); + + it('counts nothing as missed on the very first check, however old the calendar', async () => { + const rig = setup('1.0', FIRE + 60 * MIN); + rig.server.addEvent(event()); + await rig.engine.requestSync(); + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + }); + + it('reaches back a week at most', async () => { + const rig = await awayWithThree(); + rig.now = FIRE + MISSED_REACH_MS + 3 * 60 * MIN; + expect((await rig.tick()).missed).toBe(0); + }); + + it('leaves out what was deleted while away', async () => { + const rig = await awayWithThree(); + rig.now = FIRE + 3 * 60 * MIN; + expect((await rig.tick()).missed).toBe(3); + rig.server.removeEvent('e2'); + await rig.engine.requestSync(); + const batch = await rig.call('reminders.takeMissed', PARAMS); + expect(batch.alerts.map((alert) => alert.title)).toEqual(['Standup', 'Lunch']); + }); + + it('puts a summary that could not be shown back, and reads a shown one again for a late click', async () => { + const rig = await awayWithThree(); + rig.now = FIRE + 3 * 60 * MIN; + await rig.tick(); + const refused = await rig.call('reminders.takeMissed', PARAMS); + await rig.call('reminders.releaseMissed', { batch: refused.batch }); + expect((await rig.tick()).missed).toBe(3); + const shown = await rig.call('reminders.takeMissed', PARAMS); + expect(shown.alerts).toHaveLength(3); + rig.restart(); + const read = await rig.call('reminders.batch', { batch: shown.batch }); + expect(read).toHaveLength(3); + await rig.call('reminders.dismiss', { keys: shown.alerts.map((alert) => alert.key) }); + expect(await rig.call('reminders.batch', { batch: shown.batch })).toEqual([]); + }); + + it('moves a fired alert nobody could be told about to the summary', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + const [fired] = await walkTo(rig, FIRE); + await rig.call('reminders.hold', { keys: [fired.key] }); + rig.now += MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 1 }); + }); + + it('keeps quiet while reminders are off, and nothing from that time is missed later', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE + 60 * MIN; + expect(await rig.tick({ muted: true })).toMatchObject({ due: [], missed: 0, nextAt: null }); + rig.now += MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + }); +}); + +describe('snooze', () => { + async function fired(version: StalwartVersion = '1.0') { + const rig = setup(version); + rig.server.addEvent(event()); + await synced(rig); + const [alert] = await walkTo(rig, FIRE); + return { rig, alert }; + } + + it('comes back after the minutes asked for, once, and survives a restart', async () => { + const { rig, alert } = await fired(); + expect(await rig.call('reminders.snooze', { key: alert.key, minutes: 5 })).toBe(true); + rig.now = FIRE + 2 * MIN; + const during = await rig.tick(); + expect(during.due).toEqual([]); + expect(during.nextAt).toBe(FIRE + 5 * MIN); + + rig.restart(); + rig.now = FIRE + 4 * MIN; + expect((await rig.tick()).due).toEqual([]); + rig.now = FIRE + 5 * MIN; + const back = (await rig.tick()).due; + expect(back.map((a) => [a.title, a.snoozedUntil])).toEqual([['Standup', FIRE + 5 * MIN]]); + rig.now += MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('can be snoozed again', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + rig.now = FIRE + 5 * MIN; + expect((await rig.tick()).due).toHaveLength(1); + await rig.call('reminders.snooze', { key: alert.key, minutes: 10 }); + rig.now = FIRE + 15 * MIN; + const back = (await rig.tick()).due; + expect(back[0].snoozedUntil).toBe(FIRE + 15 * MIN); + }); + + it('goes to the summary when it ran out while the app was quit', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + rig.restart(); + rig.now = FIRE + 3 * 60 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 1 }); + }); + + it('does not come back for an event deleted in the meantime', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + rig.server.removeEvent('e1'); + await rig.engine.requestSync(); + rig.now = FIRE + 5 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + }); + + it('does not come back once dismissed in another window', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + await rig.call('reminders.dismiss', { keys: [alert.key] }); + rig.now = FIRE + 5 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], nextAt: null }); + }); + + it('refuses an unknown reminder and silly minutes', async () => { + const { rig, alert } = await fired(); + expect(await rig.call('reminders.snooze', { key: 'nope', minutes: 5 })).toBe(false); + expect(await rig.call('reminders.snooze', { key: alert.key, minutes: -1 })).toBe(false); + expect(await rig.call('reminders.snooze', { key: alert.key, minutes: 99_999 })).toBe(false); + }); +}); + +it('keeps reminders off the server', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + const before = JSON.stringify(rig.server.events.get('e1')); + const [alert] = await walkTo(rig, FIRE); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + await rig.call('reminders.dismiss', { keys: [alert.key] }); + await rig.engine.requestSync(); + expect(JSON.stringify(rig.server.events.get('e1'))).toBe(before); +}); diff --git a/lib/store/areas.ts b/lib/store/areas.ts index 08fe2c607..c17d15b7f 100644 --- a/lib/store/areas.ts +++ b/lib/store/areas.ts @@ -4,5 +4,6 @@ import type { StoreArea } from './area'; import { calendarArea } from './calendar'; import { mailArea } from './mail'; import { outboxArea } from './outbox'; +import { remindersArea } from './reminders'; -export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea]; +export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea, remindersArea]; diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts index b42307346..e293d1618 100644 --- a/lib/store/calendar/reminders.ts +++ b/lib/store/calendar/reminders.ts @@ -12,6 +12,7 @@ import { format, parseISO } from 'date-fns'; import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts'; import { isTaskLikeObject } from '@/lib/calendar-component-detection'; import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { findMeetingLink, type EventPlaces } from '@/lib/event-links'; import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { listObjects } from '../engine/core-schema'; @@ -22,6 +23,8 @@ export interface UpcomingAlert { /** Stable for this alert at this time. Use it to remember what was shown. */ key: string; jmap: string; + /** The series' uid, or the id where there's none: the same one whichever path shows the reminder. */ + uid: string; kind: 'event' | 'task'; /** The stored event or task: the master of a series. */ baseEventId: string; @@ -32,6 +35,10 @@ export interface UpcomingAlert { title: string; /** The start as a wall clock, or a task's due when it has no start. */ start: string | null; + /** The start as an instant, to say how long until it. Null when it can't be told. */ + startMs: number | null; + /** Joins the meeting, when the event has a video link. */ + joinUrl: string | null; timeZone: string | null; calendarId: string | null; calendarName: string | null; @@ -82,6 +89,24 @@ function withInstants(object: CalendarObject, zone: string): CalendarObject { }; } +/** + * RFC 8984 4.5.2: on a series `acknowledged` sits on the master and covers what fired + * up to then. Read as "never again", one dismissal in another app would silence every + * later occurrence. An event once is done with it either way. + */ +function acknowledgedAlready(alert: CalendarEventAlert, fireTimeMs: number, series: boolean): boolean { + if (!alert.acknowledged) return false; + if (!series) return true; + const at = new Date(alert.acknowledged).getTime(); + return Number.isNaN(at) || fireTimeMs <= at; +} + +function instant(value: string | null | undefined): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? null : ms; +} + function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } { const id = Object.keys((calendarIds ?? {}) as Record)[0] ?? null; return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null }; @@ -101,7 +126,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp const object = JSON.parse(row.json) as CalendarObject; const calendar = firstCalendar(object.calendarIds, calendars); // With the account: Stalwart's ids repeat across accounts, and one meeting can be in two. - const stableId = `${row.jmap}:${typeof object.uid === 'string' && object.uid ? object.uid : object.id}`; + const uid = typeof object.uid === 'string' && object.uid ? object.uid : object.id; + const stableId = `${row.jmap}:${uid}`; if (isTaskLikeObject(object)) { const task = object as unknown as CalendarTask; @@ -113,6 +139,7 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp add({ key: buildAlertKey(stableId, alertId, fireTimeMs), jmap: row.jmap, + uid, kind: 'task', baseEventId: object.id, recurrenceId: null, @@ -120,6 +147,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp fireTimeMs, title: task.title ?? '', start: task.start ?? task.due ?? null, + startMs: instant(task.start ?? task.due), + joinUrl: findMeetingLink(task as unknown as EventPlaces)?.uri ?? null, timeZone: task.timeZone ?? null, calendarId: calendar.id, calendarName: calendar.name, @@ -138,13 +167,14 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp if (occurrence.status === 'cancelled') continue; const isMasterOfSeries = series && occurrence === master; for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) { - if (alert.action !== 'display' || alert.acknowledged) continue; + if (alert.action !== 'display') continue; if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; const fireTimeMs = computeFireTime(occurrence, alert.trigger); - if (fireTimeMs === null) continue; + if (fireTimeMs === null || acknowledgedAlready(alert, fireTimeMs, series)) continue; add({ key: buildAlertKey(stableId, alertId, fireTimeMs), jmap: row.jmap, + uid, kind: 'event', baseEventId: object.id, recurrenceId: occurrence.recurrenceId ?? null, @@ -152,6 +182,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp fireTimeMs, title: occurrence.title ?? '', start: occurrence.start, + startMs: instant(occurrence.utcStart) ?? instant(occurrence.start), + joinUrl: findMeetingLink(occurrence as unknown as EventPlaces)?.uri ?? null, timeZone: occurrence.timeZone ?? null, calendarId: calendar.id, calendarName: calendar.name, diff --git a/lib/store/reminders/index.ts b/lib/store/reminders/index.ts new file mode 100644 index 000000000..337a16b38 --- /dev/null +++ b/lib/store/reminders/index.ts @@ -0,0 +1,20 @@ +import type { StoreArea } from '../area'; +import { dismiss, holdFired, readBatch, readReminder, releaseMissed, snooze, takeMissed, tick, type TickParams } from './ledger'; +import { REMINDER_MIGRATIONS } from './schema'; + +export const remindersArea: StoreArea = { + name: 'reminders', + migrations: REMINDER_MIGRATIONS, + methods: {}, + syncers: [], + calls: { + 'reminders.tick': (ctx, params: TickParams) => tick(ctx.db, ctx.now(), params), + 'reminders.takeMissed': (ctx, params: { timeZone: string }) => takeMissed(ctx.db, ctx.now(), params), + 'reminders.releaseMissed': (ctx, params: { batch: number }) => releaseMissed(ctx.db, ctx.now(), params.batch), + 'reminders.batch': (ctx, params: { batch: number }) => readBatch(ctx.db, params.batch), + 'reminders.hold': (ctx, params: { keys: string[] }) => holdFired(ctx.db, ctx.now(), params.keys), + 'reminders.snooze': (ctx, params: { key: string; minutes: number }) => snooze(ctx.db, ctx.now(), params.key, params.minutes), + 'reminders.dismiss': (ctx, params: { keys: string[] }) => dismiss(ctx.db, ctx.now(), params.keys), + 'reminders.get': (ctx, params: { key: string }) => readReminder(ctx.db, params.key), + }, +}; diff --git a/lib/store/reminders/ledger.ts b/lib/store/reminders/ledger.ts new file mode 100644 index 000000000..3f82a4fb6 --- /dev/null +++ b/lib/store/reminders/ledger.ts @@ -0,0 +1,229 @@ +/** + * What has become of each reminder on this device: the one place that decides what + * fires. Every call is one synchronous transaction in the store's worker, and all tabs + * and windows reach the same worker, so whichever asks first gets an alert and nobody + * else ever does, across restarts too. localStorage can't do this: a worker can't read + * it, and two tabs can read it at the same moment. + * + * A row's status: + * - `fired`: shown, nothing more to do. + * - `snoozed`: comes back at `snooze_until`. + * - `missed`: waits for a summary, because the device was away or notifications were + * paused. + * - `summarised`: went into the summary `batch`. + * - `dismissed`: the user is done with it. + * + * None of this goes to the server. JSCalendar's `acknowledged` is read (reminders.ts), + * never written: decisions file, reminders. + */ + +import { metaGet, metaSet } from '../engine/core-schema'; +import { listUpcomingAlerts, type UpcomingAlert } from '../calendar/reminders'; +import type { StorageAdapter } from '../storage'; + +/** Younger than this, an alert still fires by itself. As lib/calendar-alerts.ts. */ +export const FRESH_MS = 10 * 60_000; +/** A gap between two checks longer than this means the device was away. A little over the page's refresh. */ +export const AWAY_MS = 6 * 60_000; +/** How far back a summary reaches. After a month away nobody needs a month of reminders. */ +export const MISSED_REACH_MS = 7 * 24 * 3_600_000; +/** How far ahead a check looks for the next fire time. */ +export const LOOK_AHEAD_MS = 6 * 3_600_000; +/** Rows older than the reach can't come up again. */ +const KEEP_MS = MISSED_REACH_MS + 24 * 3_600_000; +const CURSOR = 'reminder_cursor'; +const LONGEST_SNOOZE_MIN = 24 * 60; + +export interface ReminderAlert extends UpcomingAlert { + /** Set when it comes back from a snooze: when that was due. */ + snoozedUntil?: number; +} + +export interface TickParams { + /** Reads floating events: the user's zone. */ + timeZone: string; + /** Tasks' alerts too. */ + tasks: boolean; + /** Reminders are switched off: time passes, and nothing counts as missed later. */ + muted?: boolean; +} + +export interface TickResult { + /** Yours to show. No other caller gets these. */ + due: ReminderAlert[]; + /** How many wait for a summary. */ + missed: number; + /** When to ask again at the latest for the next reminder. Null: none in sight. */ + nextAt: number | null; + /** The calendar has synced at least once, so an empty answer means something. */ + ready: boolean; +} + +interface Row { + key: string; + status: string; + fire_time: number; + snooze_until: number | null; + alert: string; +} + +function put(db: StorageAdapter, alert: ReminderAlert, status: string, now: number): void { + db.run( + `INSERT INTO reminder (key, status, fire_time, snooze_until, batch, alert, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?) + ON CONFLICT (key) DO UPDATE SET status = excluded.status, snooze_until = NULL, batch = NULL, alert = excluded.alert, updated_at = excluded.updated_at`, + [alert.key, status, alert.fireTimeMs, JSON.stringify(alert), now], + ); +} + +function readAlert(row: Pick): ReminderAlert { + return JSON.parse(row.alert) as ReminderAlert; +} + +function missedCount(db: StorageAdapter): number { + return Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM reminder WHERE status = 'missed'")?.n ?? 0); +} + +function calendarReady(db: StorageAdapter): boolean { + return !!db.get("SELECT 1 AS yes FROM sync_state WHERE type = 'CalendarEvent' AND state IS NOT NULL LIMIT 1"); +} + +/** Whether a snoozed alert still stands: the event may have gone, moved or been dismissed elsewhere. */ +function alertNow(db: StorageAdapter, row: Pick, timeZone: string): UpcomingAlert | undefined { + return listUpcomingAlerts(db, { from: row.fire_time, to: row.fire_time + 1, timeZone }).find((alert) => alert.key === row.key); +} + +/** + * Hands out what's due and settles the rest. Alerts that came due while the device was + * away go to the summary instead of arriving as a burst. A single recent one is just a + * reminder that came a little late. + */ +export function tick(db: StorageAdapter, now: number, params: TickParams): TickResult { + return db.transaction(() => { + const raw = metaGet(db, CURSOR); + const cursor = raw === null ? null : Number(raw); + metaSet(db, CURSOR, String(now)); + if (params.muted) return { due: [], missed: missedCount(db), nextAt: null, ready: calendarReady(db) }; + + const freshFrom = now - FRESH_MS; + const from = cursor === null ? freshFrom : Math.max(Math.min(cursor, freshFrom), now - MISSED_REACH_MS); + const listed = listUpcomingAlerts(db, { from, to: now + LOOK_AHEAD_MS, timeZone: params.timeZone }) + .filter((alert) => params.tasks || alert.kind !== 'task'); + const past = listed.filter((alert) => alert.fireTimeMs <= now); + const known = new Set( + past.length === 0 + ? [] + : db.all<{ key: string }>('SELECT key FROM reminder WHERE fire_time >= ? AND fire_time <= ?', [past[0].fireTimeMs, past[past.length - 1].fireTimeMs]).map((row) => row.key), + ); + const unseen = past.filter((alert) => !known.has(alert.key)); + + const away = cursor === null || now - cursor > AWAY_MS; + const lastSeen = cursor ?? freshFrom; + // Behind the cursor: it reached the store late, through a sync. Nobody was away from that. + const inGap = away ? unseen.filter((alert) => alert.fireTimeMs > lastSeen) : []; + const summarise = new Set( + inGap.length === 1 && inGap[0].fireTimeMs >= freshFrom ? [] : inGap.map((alert) => alert.key), + ); + const due: ReminderAlert[] = []; + for (const alert of unseen) { + if (summarise.has(alert.key) || alert.fireTimeMs < freshFrom) { + put(db, alert, 'missed', now); + } else { + put(db, alert, 'fired', now); + due.push(alert); + } + } + + const expired = db.all("SELECT key, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'snoozed' AND snooze_until <= ?", [now]); + for (const row of expired) { + const alert = alertNow(db, row, params.timeZone); + if (!alert) { + db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, updated_at = ? WHERE key = ?", [now, row.key]); + continue; + } + if (now - row.snooze_until! < FRESH_MS) { + const back: ReminderAlert = { ...alert, snoozedUntil: row.snooze_until! }; + put(db, back, 'fired', now); + due.push(back); + } else { + put(db, alert, 'missed', now); + } + } + + db.run("DELETE FROM reminder WHERE fire_time < ? AND status NOT IN ('snoozed', 'missed')", [now - KEEP_MS]); + + const coming = listed.find((alert) => alert.fireTimeMs > now)?.fireTimeMs ?? null; + const snooze = db.get<{ at: number | null }>("SELECT MIN(snooze_until) AS at FROM reminder WHERE status = 'snoozed'")?.at ?? null; + const nextAt = coming === null ? snooze : snooze === null ? coming : Math.min(coming, snooze); + return { due, missed: missedCount(db), nextAt, ready: calendarReady(db) }; + }); +} + +export interface MissedBatch { + /** Says which summary, for a toast clicked later. */ + batch: number; + alerts: ReminderAlert[]; +} + +/** + * The summary, for one caller only. What's gone from the calendar since doesn't come + * back to haunt anyone. Null: nothing to say. + */ +export function takeMissed(db: StorageAdapter, now: number, params: { timeZone: string }): MissedBatch | null { + return db.transaction(() => { + const rows = db.all("SELECT key, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'missed' ORDER BY fire_time, key"); + if (rows.length === 0) return null; + const standing = new Map( + listUpcomingAlerts(db, { from: rows[0].fire_time, to: rows[rows.length - 1].fire_time + 1, timeZone: params.timeZone }) + .map((alert) => [alert.key, alert]), + ); + const newest = db.get<{ batch: number | null }>('SELECT MAX(batch) AS batch FROM reminder')?.batch ?? 0; + const batch = Math.max(now, newest + 1); + const alerts: ReminderAlert[] = []; + for (const row of rows) { + const alert = standing.get(row.key); + if (!alert) { + db.run("UPDATE reminder SET status = 'dismissed', updated_at = ? WHERE key = ?", [now, row.key]); + continue; + } + db.run("UPDATE reminder SET status = 'summarised', batch = ?, alert = ?, updated_at = ? WHERE key = ?", [batch, JSON.stringify(alert), now, row.key]); + alerts.push(alert); + } + return alerts.length > 0 ? { batch, alerts } : null; + }); +} + +/** The summary couldn't be shown after all (paused, quiet hours): it stays due. */ +export function releaseMissed(db: StorageAdapter, now: number, batch: number): void { + db.run("UPDATE reminder SET status = 'missed', batch = NULL, updated_at = ? WHERE status = 'summarised' AND batch = ?", [now, batch]); +} + +/** What a summary said, for a click on it, possibly after a restart. */ +export function readBatch(db: StorageAdapter, batch: number): ReminderAlert[] { + return db.all("SELECT alert FROM reminder WHERE status = 'summarised' AND batch = ? ORDER BY fire_time, key", [batch]).map(readAlert); +} + +/** Fired, but nobody could be told: it goes in the next summary. */ +export function holdFired(db: StorageAdapter, now: number, keys: readonly string[]): void { + db.transaction(() => { + for (const key of keys) db.run("UPDATE reminder SET status = 'missed', updated_at = ? WHERE status = 'fired' AND key = ?", [now, key]); + }); +} + +/** False when this device knows no such reminder, or the minutes make no sense. */ +export function snooze(db: StorageAdapter, now: number, key: string, minutes: number): boolean { + if (!Number.isFinite(minutes) || minutes <= 0 || minutes > LONGEST_SNOOZE_MIN) return false; + const until = now + minutes * 60_000; + return db.run("UPDATE reminder SET status = 'snoozed', snooze_until = ?, batch = NULL, updated_at = ? WHERE key = ?", [until, now, key]).changes > 0; +} + +export function dismiss(db: StorageAdapter, now: number, keys: readonly string[]): void { + db.transaction(() => { + for (const key of keys) db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, updated_at = ? WHERE key = ?", [now, key]); + }); +} + +/** One reminder as it was last shown: what a toast's Join reads, since the toast holds only the key. */ +export function readReminder(db: StorageAdapter, key: string): ReminderAlert | null { + const row = db.get>('SELECT alert FROM reminder WHERE key = ?', [key]); + return row ? readAlert(row) : null; +} diff --git a/lib/store/reminders/schema.ts b/lib/store/reminders/schema.ts new file mode 100644 index 000000000..445e368e1 --- /dev/null +++ b/lib/store/reminders/schema.ts @@ -0,0 +1,20 @@ +import type { Migration } from '../storage'; + +export const REMINDER_MIGRATIONS: readonly Migration[] = [ + { + id: 850, + name: 'reminders', + sql: ` + CREATE TABLE reminder ( + key TEXT PRIMARY KEY, + status TEXT NOT NULL, + fire_time INTEGER NOT NULL, + snooze_until INTEGER, + batch INTEGER, + alert TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) WITHOUT ROWID; + CREATE INDEX reminder_status ON reminder (status, snooze_until); + `, + }, +]; From 7c8ae2fe89b15c4d363f0bf0348068ca23da5eb2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:29:11 +0100 Subject: [PATCH 11/45] fix: keep a hidden composer from taking files dropped on the window --- components/email/email-composer.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index f0dab3ae6..be8263737 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2650,12 +2650,6 @@ export function EmailComposer({ resetDragTimeout(); }, [resetDragTimeout]); - // 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]); - const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -2705,6 +2699,12 @@ export function EmailComposer({ const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar'); // The plugin side panels fold when the composer gets too narrow for them. const [composerRootEl, setComposerRootEl] = useState(null); + + // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen. + useEffect(() => setComposerFileTarget( + (files) => void addFiles(files), + () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0, + ), [addFiles, composerRootEl]); // The id plugins know this composer by (registered below, with the handle). const [pluginComposerId, setPluginComposerId] = useState(null); const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId); From a802411cddfa6d2855b3590ca1bedd1508dc54de Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:31:58 +0100 Subject: [PATCH 12/45] feat: fire calendar reminders from the local store, with native snooze and join buttons and one summary of what was missed --- .../calendar/missed-reminders-dialog.tsx | 70 ++++ components/desktop/desktop-os-host.tsx | 19 - .../providers/calendar-alert-provider.tsx | 4 + .../ui/__tests__/toast-extra-actions.test.tsx | 33 ++ components/ui/toast.tsx | 3 + .../use-calendar-alerts-hooks.test.tsx | 48 +++ hooks/__tests__/use-store-reminders.test.tsx | 380 ++++++++++++++++++ hooks/use-calendar-alerts.ts | 136 ++++--- hooks/use-store-reminders.ts | 278 +++++++++++++ .../__tests__/claims.test.ts | 98 +++++ .../__tests__/runner.test.ts | 268 ++++++++++++ lib/calendar-reminders/channel.ts | 32 ++ lib/calendar-reminders/claims.ts | 70 ++++ lib/calendar-reminders/runner.ts | 156 +++++++ lib/calendar-reminders/state.ts | 55 +++ lib/calendar-reminders/system-notification.ts | 70 ++++ .../__tests__/reminder-notifications.test.ts | 239 +++++++++++ lib/desktop/os-commands.ts | 2 + lib/desktop/reminder-notifications.ts | 218 ++++++++++ lib/desktop/types.ts | 2 + lib/offline/offline-mode.ts | 2 + lib/offline/store-events.ts | 18 + lib/sign-out-cleanup.ts | 1 + locales/ar/common.json | 14 + locales/ca/common.json | 14 + locales/cs/common.json | 14 + locales/da/common.json | 14 + locales/de/common.json | 14 + locales/en/common.json | 14 + locales/es/common.json | 14 + locales/et/common.json | 14 + locales/fa/common.json | 14 + locales/fr/common.json | 14 + locales/he/common.json | 14 + locales/hu/common.json | 14 + locales/it/common.json | 14 + locales/ja/common.json | 14 + locales/ko/common.json | 14 + locales/lv/common.json | 14 + locales/mn/common.json | 14 + locales/nb/common.json | 14 + locales/nl/common.json | 14 + locales/pl/common.json | 14 + locales/pt/common.json | 14 + locales/ro/common.json | 14 + locales/ru/common.json | 14 + locales/sk/common.json | 14 + locales/th/common.json | 14 + locales/tr/common.json | 14 + locales/uk/common.json | 14 + locales/vi/common.json | 14 + locales/zh-TW/common.json | 14 + locales/zh/common.json | 14 + public/sw.js | 1 + stores/calendar-notification-store.ts | 2 + 55 files changed, 2555 insertions(+), 70 deletions(-) create mode 100644 components/calendar/missed-reminders-dialog.tsx create mode 100644 components/ui/__tests__/toast-extra-actions.test.tsx create mode 100644 hooks/__tests__/use-store-reminders.test.tsx create mode 100644 hooks/use-store-reminders.ts create mode 100644 lib/calendar-reminders/__tests__/claims.test.ts create mode 100644 lib/calendar-reminders/__tests__/runner.test.ts create mode 100644 lib/calendar-reminders/channel.ts create mode 100644 lib/calendar-reminders/claims.ts create mode 100644 lib/calendar-reminders/runner.ts create mode 100644 lib/calendar-reminders/state.ts create mode 100644 lib/calendar-reminders/system-notification.ts create mode 100644 lib/desktop/__tests__/reminder-notifications.test.ts create mode 100644 lib/desktop/reminder-notifications.ts create mode 100644 lib/offline/store-events.ts diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx new file mode 100644 index 000000000..6309994fd --- /dev/null +++ b/components/calendar/missed-reminders-dialog.tsx @@ -0,0 +1,70 @@ +"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, 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); + + 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 ( + + + + + )} + > +
    + {alerts.map((alert) => ( +
  • +
    +

    {alert.title || tAlert("alert_title")}

    +

    + {format.format(new Date(alert.startMs ?? alert.fireTimeMs))} + {alert.calendarName ? ` · ${alert.calendarName}` : ""} +

    +
    + {alert.joinUrl && ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx index c8d2755d8..df31098c0 100644 --- a/components/desktop/desktop-os-host.tsx +++ b/components/desktop/desktop-os-host.tsx @@ -9,11 +9,8 @@ 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 { calendarHooks } from "@/lib/plugin-hooks"; -import type { CalendarAlertView } from "@/lib/plugin-types"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; -import { useSettingsStore } from "@/stores/settings-store"; /** * The main window's end of the desktop app's OS integration: new mail as native @@ -25,7 +22,6 @@ export function DesktopOsHost() { const t = useTranslations("desktopOs"); const tNotify = useTranslations("notifications"); const tPush = useTranslations("pwaNotify"); - const tCalendar = useTranslations("calendar.notifications"); const locale = useLocale(); const archiveEnabled = useArchiveActionEnabled(); const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n")); @@ -56,29 +52,14 @@ export function DesktopOsHost() { summaryRef.current = summary; const stopPower = startPowerWatch(); const stopFiles = startWindowFiles(); - // Reminders' own native notification (snooze, join) comes with the reminders work. - // Until then a reminder must not fire into a hidden window unseen. - const reminders = calendarHooks.onCalendarAlert.register("desktop-os", (view: CalendarAlertView) => { - const { calendarNotificationsEnabled } = useSettingsStore.getState(); - if (!calendarNotificationsEnabled) return; - const event = "eventId" in view; - void getDesktopBridge()?.os?.notify({ - id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`, - title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"), - body: "", - context: "{}", - }); - }); return () => { mail.stop(); summary.stop(); stopPower(); stopFiles(); - reminders.dispose(); mailRef.current = null; summaryRef.current = null; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/components/providers/calendar-alert-provider.tsx b/components/providers/calendar-alert-provider.tsx index cb0b72b6a..561586746 100644 --- a/components/providers/calendar-alert-provider.tsx +++ b/components/providers/calendar-alert-provider.tsx @@ -1,12 +1,15 @@ "use client"; +import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog'; import { useCalendarAlerts } from '@/hooks/use-calendar-alerts'; +import { useStoreReminders } from '@/hooks/use-store-reminders'; import { ToastContainer } from '@/components/ui/toast'; import { LiveRegions } from '@/components/ui/live-regions'; import { useToastStore } from '@/stores/toast-store'; export function CalendarAlertProvider({ children }: { children: React.ReactNode }) { useCalendarAlerts(); + useStoreReminders(); const toasts = useToastStore((s) => s.toasts); const removeToast = useToastStore((s) => s.removeToast); @@ -15,6 +18,7 @@ export function CalendarAlertProvider({ children }: { children: React.ReactNode <> {children} + ); diff --git a/components/ui/__tests__/toast-extra-actions.test.tsx b/components/ui/__tests__/toast-extra-actions.test.tsx new file mode 100644 index 000000000..10354d4f3 --- /dev/null +++ b/components/ui/__tests__/toast-extra-actions.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ToastItem } from '../toast'; + +describe('a toast with more than three actions', () => { + it('shows them in order, and each closes it', () => { + vi.useFakeTimers(); + const clicked = vi.fn(); + const onDismiss = vi.fn(); + const action = (label: string) => ({ label, onClick: () => clicked(label) }); + render( + , + ); + expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['five', 'ten', 'fifteen', 'join', 'open']); + fireEvent.click(screen.getByText('join')); + expect(clicked).toHaveBeenCalledWith('join'); + expect(onDismiss).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 28bb16c0a..a3d96a2be 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -36,6 +36,8 @@ export interface Toast { * that has one lays its actions out on a row of their own. */ tertiaryAction?: ToastAction; + /** More actions at the end of that row. Needs a tertiaryAction. */ + extraActions?: ToastAction[]; /** * Work in progress, e.g. an upload: 0..1, or null while the amount is * unknown. The bottom line then shows progress instead of a countdown, @@ -291,6 +293,7 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr {actionButton(toast.tertiaryAction)} {toast.secondaryAction && actionButton(toast.secondaryAction)} {toast.action && actionButton(toast.action)} + {toast.extraActions?.map((action) => {actionButton(action)})} )} diff --git a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx index 331953d64..0f3ca9527 100644 --- a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx +++ b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx @@ -76,6 +76,54 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('the 24-hour fetch', () => { + it('fires the occurrence of a series the server answered with its master', async () => { + const now = Date.now(); + const occurrenceStart = new Date(now + 5 * 60_000); + const stamp = (date: Date) => { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`; + }; + // The same weekday and time, years ago: only the expansion finds today's. + const first = new Date(occurrenceStart.getTime() - 52 * 7 * 24 * 3_600_000); + const master = { + id: 'series', + uid: 'uid-series', + title: 'Weekly', + start: stamp(first), + duration: 'PT30M', + calendarIds: { c1: true }, + useDefaultAlerts: false, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], + alerts: { + a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT6M', relativeTo: 'start' } }, + }, + } as unknown as CalendarEvent; + useCalendarStore.setState({ events: [], calendars: [], supportsCalendar: true }); + useAuthStore.setState({ isAuthenticated: true, client: { queryCalendarEvents: vi.fn(async () => [master]) } as never }); + + renderHook(() => useCalendarAlerts()); + + await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['Weekly'])); + }); +}); + +describe('two tabs', () => { + it('rings in one, also when an id changed between the two', async () => { + const synthetic = { ...event, id: 'synthetic-2' } as CalendarEvent; + renderHook(() => useCalendarAlerts()); + await waitFor(() => expect(useToastStore.getState().toasts).toHaveLength(1)); + + // The other tab has the same reminder under the id the server gives it now. + useCalendarStore.setState({ events: [synthetic] }); + renderHook(() => useCalendarAlerts()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 700)); + }); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); +}); + describe('reminder hooks', () => { it('reports a fired reminder once, and its dismissal when the user closes it', async () => { const fired = vi.fn(); diff --git a/hooks/__tests__/use-store-reminders.test.tsx b/hooks/__tests__/use-store-reminders.test.tsx new file mode 100644 index 000000000..afcad0f8a --- /dev/null +++ b/hooks/__tests__/use-store-reminders.test.tsx @@ -0,0 +1,380 @@ +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() })); +vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true })); +const popout = vi.hoisted(() => ({ value: false })); +vi.mock('@/lib/popout/window-role', async (original) => ({ + ...await original(), + isPopoutWindow: () => popout.value, +})); + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; +const LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +let notifyResult: DesktopOsNotifyResult; +const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult); +const openExternal = vi.fn(); + +function desktop() { + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +function Toasts() { + const toasts = useToastStore((s) => s.toasts); + const removeToast = useToastStore((s) => s.removeToast); + return ; +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +async function syncedWith(...events: Record[]) { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title); + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + vi.clearAllMocks(); + popout.value = false; + clearAllHooks(); + localStorage.clear(); + setAppLockCheck(null); + setVisibility('visible'); + notifyResult = { shown: true, playSound: false }; + useToastStore.setState({ toasts: [] }); + useReminderSource.setState({ driven: {} }); + useMissedReminders.getState().close(); + useCalendarNotificationStore.getState().clearAll(); + useOfflineStore.setState({ enabled: [ACCOUNT] }); + useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT }); + useCalendarStore.setState({ events: [], calendars: [] }); + useSettingsStore.setState({ + calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true, + doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin', + }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('in the desktop app', () => { + beforeEach(desktop); + + it('fires a native notification with the window hidden, at the time, with the network gone', async () => { + await syncedWith(event(LINK)); + await store.setOnline(false); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() - 1000); + expect(notify).not.toHaveBeenCalled(); + await advance(1000); + expect(notify).toHaveBeenCalledTimes(1); + const sent = notify.mock.calls[0][0]; + expect(sent.title).toBe('Standup'); + expect(sent.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']); + expect(toastTitles()).toEqual([]); + }); + + it('shows no Join for an event without a link', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].actions!.map((action) => action.id)).not.toContain('join'); + }); + + it('plays the sound main asks for, and asks for none in Do not disturb', async () => { + await syncedWith(event()); + notifyResult = { shown: true, playSound: true }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].sound).toBe(true); + expect(sounds.play).toHaveBeenCalledTimes(1); + + useSettingsStore.setState({ doNotDisturb: true }); + await syncedWith(event({ id: 'e2', uid: 'uid-2', start: '2026-11-10T10:01:00' })); + emitStoreEvent({ type: 'changed', account: ACCOUNT, collections: ['CalendarEvent'] }); + await advance(MIN); + expect(notify.mock.calls[1][0].sound).toBe(false); + }); + + it('shows it in the app when the window is in front, with the same choices', async () => { + await syncedWith(event(LINK)); + notifyResult = { shown: false, reason: 'focused', playSound: true }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(toastTitles()).toEqual(['Standup']); + expect(sounds.play).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['snooze', 'snooze', 'snooze', 'join', 'open']); + fireEvent.click(screen.getByText('join')); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup'); + }); + + it('snoozing from the in-app toast brings it back, and closing it does not', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + fireEvent.click(screen.getAllByText('snooze')[0]); + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + + await advance(1000); + const again = useToastStore.getState().toasts.at(-1)!; + act(() => again.onDismiss!()); + await advance(60 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + }); + + it('holds what fires while paused or in quiet hours for the summary afterwards', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'quiet-hours', playSound: false }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify).toHaveBeenCalledTimes(1); + expect(toastTitles()).toEqual([]); + + // Still quiet: the summary is tried and put back. + await advance(5 * MIN); + const tried = notify.mock.calls.length; + expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/); + + notifyResult = { shown: true, playSound: false }; + await advance(5 * MIN); + expect(notify.mock.calls.length).toBe(tried + 1); + expect(notify.mock.calls.at(-1)![0].body).toBe('Standup'); + + await advance(30 * MIN); + expect(notify.mock.calls.length).toBe(tried + 1); + }); + + it('says nothing of the event while locked, and tells afterwards', async () => { + await syncedWith(event()); + let locked = true; + setAppLockCheck(() => locked); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify).toHaveBeenCalledTimes(1); + expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup'); + expect(notify.mock.calls[0][0].actions).toBeUndefined(); + expect(toastTitles()).toEqual([]); + + await advance(10 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + + locked = false; + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls[1][0].body).toBe('Standup'); + }); + + it('summarises what was missed once, with a native notification, across a restart', async () => { + await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); + + const after = renderHook(() => useStoreReminders()); + await advance(20_000); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify.mock.calls[0][0]).toMatchObject({ title: 'missed_title', body: 'Standup, Review' }); + after.unmount(); + + renderHook(() => useStoreReminders()); + await advance(60 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('summarises in the app when the window is in front, and the dialog lists them', async () => { + await syncedWith(event(LINK), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); + render(<>); + renderHook(() => useStoreReminders()); + await advance(20_000); + expect(toastTitles()).toEqual(['missed_title']); + fireEvent.click(screen.getByText('missed_show')); + await advance(0); + const list = screen.getByTestId('missed-reminders'); + expect(list.textContent).toContain('Standup'); + expect(list.textContent).toContain('Review'); + expect(screen.getAllByText('join')).toHaveLength(1); + fireEvent.click(screen.getByText('done')); + await advance(0); + expect(screen.queryByTestId('missed-reminders')).toBeNull(); + }); + + it('tells plugins, and lets one take the reminder over', async () => { + await syncedWith(event()); + const fired = vi.fn(); + calendarHooks.onCalendarAlert.register('t', fired); + calendarHooks.onBeforeCalendarAlert.register('t', () => false); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(fired).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', eventId: 'e1', eventTitle: 'Standup' })); + expect(notify).not.toHaveBeenCalled(); + }); + + function calendarStoreHas(uid: string, title: string) { + useCalendarStore.setState({ + events: [{ id: 'e1', uid, title, start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + } + + it('makes the old path stand down once the store has this account', async () => { + await syncedWith(event()); + // Not the same event as far as the claim can tell: only standing down keeps it quiet. + calendarStoreHas('another-uid', 'Old path'); + renderHook(() => { + useCalendarAlerts(); + useStoreReminders(); + }); + await advance(FIRE - Date.now() + 2 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + expect(toastTitles()).toEqual([]); + }); + + it('lets the two paths tell each other what fired, for the moment the store takes over', async () => { + await syncedWith(event()); + calendarStoreHas('uid-1', 'Standup'); + const { claimKey, claimNow } = await import('@/lib/calendar-reminders/claims'); + // The old path got there just before the store was ready. + expect(claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE)).toBe(true); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 2 * MIN); + expect(notify).not.toHaveBeenCalled(); + }); +}); + +describe('in a browser', () => { + function grantNotifications(showNotification = vi.fn(async () => {})) { + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' })); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) }, + }); + return showNotification; + } + + afterEach(() => { + Reflect.deleteProperty(navigator, 'serviceWorker'); + }); + + it('keeps the in-app toast, without desktop buttons', async () => { + await syncedWith(event(LINK)); + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + const toast = useToastStore.getState().toasts[0]; + expect(toast.title).toBe('Standup'); + expect(toast.action).toBeUndefined(); + expect(toast.extraActions).toBeUndefined(); + expect(sounds.play).toHaveBeenCalledTimes(1); + }); + + it('adds a system notification only where permission is granted and the tab is out of view', async () => { + await syncedWith(event()); + const shown = grantNotifications(); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(toastTitles()).toEqual(['Standup']); + expect(shown).toHaveBeenCalledTimes(1); + expect(shown).toHaveBeenCalledWith('Standup', expect.objectContaining({ data: { kind: 'calendar-reminder' }, silent: false })); + }); + + it('asks for no permission and shows none when it was not granted', async () => { + await syncedWith(event()); + const request = vi.fn(); + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'default', requestPermission: request })); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(toastTitles()).toEqual(['Standup']); + expect(request).not.toHaveBeenCalled(); + }); + + it('fires in one of two tabs', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(toastTitles()).toEqual(['Standup']); + }); + + it('closes the toast in this window when another acknowledges the reminder', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(toastTitles()).toEqual(['Standup']); + const key = `${Object.keys(CALENDAR_SESSION.accounts)[0]}:uid-1:a:${FIRE}`; + const other = new BroadcastChannel('bulwark-calendar-reminders'); + other.postMessage({ kind: 'acknowledged', keys: [key] }); + other.close(); + vi.useRealTimers(); + await vi.waitFor(() => expect(toastTitles()).toEqual([])); + }); + + it('gives nothing to a pop-out', async () => { + popout.value = true; + await syncedWith(event()); + const area = vi.spyOn(store, 'area'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(area).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + }); +}); diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts index 8c65c12ee..365647480 100644 --- a/hooks/use-calendar-alerts.ts +++ b/hooks/use-calendar-alerts.ts @@ -2,7 +2,7 @@ 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'; @@ -11,10 +11,16 @@ 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 { getDesktopBridge } from '@/lib/desktop/bridge'; import type { CalendarEvent } from '@/lib/jmap/types'; import type { CalendarAlertView } from '@/lib/plugin-types'; import { calendarHooks } from '@/lib/plugin-hooks'; @@ -23,6 +29,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". */ @@ -42,7 +50,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; } @@ -51,7 +59,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); } @@ -61,7 +69,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(); @@ -73,9 +81,11 @@ 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]); // The main window rings; a pop-out would ring the same alert again. - const isAuthenticated = signedIn && !isPopoutWindow(); + const isAuthenticated = signedIn && !isPopoutWindow() && !fromStore; const { events, calendars, supportsCalendar } = useCalendarStore(); const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore(); const { tasks: storeTasks } = useTaskStore(); @@ -91,6 +101,15 @@ export function useCalendarAlerts() { const wakeTimerRef = useRef | null>(null); const checkAlertsRef = useRef<() => void>(() => {}); + // Beside the toast: the desktop app's native notification, a browser's system one. + const tellOutsideTheWindow = useCallback((key: string, title: string, body: string, silent: boolean) => { + if (getDesktopBridge()?.os) { + notifyWithoutStore(`reminder:${key}`, title, body, { generic: t('alert_title') }, activeAccountId); + return; + } + void showSystemReminder({ key, title, body, silent }, `${getPathPrefix(locale)}/${locale}/calendar`); + }, [t, locale, activeAccountId]); + const checkAlerts = useCallback(() => { if (wakeTimerRef.current) { clearTimeout(wakeTimerRef.current); @@ -127,27 +146,33 @@ 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: () => { - window.location.href = `${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 sound = reminderSoundOn(calendarNotificationSound); + if (sound) { + playNotificationSound(notificationSoundChoice); + } + const title = alert.event.title || t('alert_title'); + reminderTitleAlert(t('title_alert', { title })); + addToast({ + type: 'info', + title, + message, + duration: reminderToastDuration(), + onClick: () => { + window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; + }, + onDismiss: () => { + void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId }); + }, + }); + tellOutsideTheWindow(key, title, message, !sound); }); }); } @@ -166,27 +191,33 @@ 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: () => { - window.location.href = `${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 sound = reminderSoundOn(calendarNotificationSound); + if (sound) { + playNotificationSound(notificationSoundChoice); + } + const title = taskAlert.task.title || t('alert_title'); + reminderTitleAlert(t('title_alert', { title })); + addToast({ + type: 'info', + title, + message: taskMsg, + duration: reminderToastDuration(), + onClick: () => { + window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; + }, + onDismiss: () => { + void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId }); + }, + }); + tellOutsideTheWindow(key, title, taskMsg, !sound); }); }); } @@ -196,8 +227,8 @@ export function useCalendarAlerts() { } }, [ calendarNotificationsEnabled, calendarNotificationSound, notificationSoundChoice, - isAuthenticated, events, calendars, acknowledgedAlerts, - acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, + isAuthenticated, activeAccountId, events, calendars, acknowledgedAlerts, + acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, tellOutsideTheWindow, ]); checkAlertsRef.current = checkAlerts; @@ -211,7 +242,10 @@ export function useCalendarAlerts() { const start = new Date(now - 10 * 60 * 1000).toISOString(); const end = new Date(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000).toISOString(); const fetched = await client.queryCalendarEvents({ after: start, before: end }); - proactiveEventsRef.current = fetched; + // Without synthetic ids the server answers with a series' master, whose start can + // be years ago: only its occurrences have a fire time. Passes real occurrences through. + const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss"); + proactiveEventsRef.current = expandRecurringEvents(fetched, stamp(now - EXPAND_MARGIN_MS), stamp(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000 + EXPAND_MARGIN_MS)); lastProactiveFetchRef.current = Date.now(); checkAlertsRef.current(); } catch { diff --git a/hooks/use-store-reminders.ts b/hooks/use-store-reminders.ts new file mode 100644 index 000000000..adeb0468b --- /dev/null +++ b/hooks/use-store-reminders.ts @@ -0,0 +1,278 @@ +"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, claimNow } 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 { + buildMissedNotification, + buildReminderNotification, + dismissReminders, + 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 { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +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'), + missedTitle: (count) => t('missed_title', { count }), + missedMore: (count) => t('missed_more', { count }), + }; + const labelsRef = useRef(labels); + labelsRef.current = labels; + const calendarUrl = `${getPathPrefix(locale)}/${locale}/calendar`; + const calendarUrlRef = useRef(calendarUrl); + calendarUrlRef.current = calendarUrl; + const runnerRef = useRef | null>(null); + + useEffect(() => { + if (!signedIn || isPopoutWindow()) return; + const toastsByKey = new Map(); + + const 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: current.join, onClick: () => joinMeeting(alert.joinUrl!) }] : []), + { 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)); + // Paused, quiet hours, or nothing may be said now: the summary says it later. + if (locked || result.reason === 'paused' || result.reason === 'quiet-hours') { + 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, + }, calendarUrlRef.current); + }; + + const showMissed = async (account: string) => { + if (isAppLocked()) return; + const timeZone = reminderTimeZone(useSettingsStore.getState().timeZone); + const batch = await getStore().area(account, 'reminders.takeMissed', { timeZone }); + if (!batch) return; + const current = labelsRef.current; + const os = getDesktopBridge()?.os; + if (os) { + const result = await os.notify(await buildMissedNotification(account, batch, current)); + if (result.shown) return; + // Told nobody, so it has to wait for a time when it can. + if (result.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'duplicate') { + await getStore().area(account, 'reminders.releaseMissed', { 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); + if (!os) { + void showSystemReminder({ + key: `missed:${batch.batch}`, + title: current.missedTitle(batch.alerts.length), + body, + silent: true, + }, calendarUrlRef.current); + } + }; + + 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, + }; + }, + hidden: () => document.visibilityState === 'hidden', + offline: () => navigator.onLine === false, + onDue: (account, alerts) => { + for (const alert of alerts) { + // Whichever of the two paths got there first: one comes back from a snooze. + const key = claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs); + if (!claimNow(key, alert.fireTimeMs) && !alert.snoozedUntil) 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(); + runnerRef.current = null; + setReminderPoke(null); + stopEvents(); + stopWake(); + stopAcks(); + window.removeEventListener('online', onOnline); + document.removeEventListener('visibilitychange', onVisible); + }; + // Labels go through a ref: a language change mustn't restart the check. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signedIn]); + + // An account just turned on or off. + useEffect(() => { + runnerRef.current?.poke(); + }, [enabledKey]); +} diff --git a/lib/calendar-reminders/__tests__/claims.test.ts b/lib/calendar-reminders/__tests__/claims.test.ts new file mode 100644 index 000000000..862738746 --- /dev/null +++ b/lib/calendar-reminders/__tests__/claims.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { claimAlert, claimKey, claimNow, CLAIMS_STORAGE_KEY, HIDDEN_GRACE_MS } from '../claims'; + +const FIRE = Date.UTC(2026, 10, 10, 9); + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +/** A Web Lock: one holder at a time, across "tabs". */ +function fakeLocks() { + let tail: Promise = Promise.resolve(); + return { + request: vi.fn((_name: string, callback: () => T | Promise) => { + const run = tail.then(callback); + tail = run.catch(() => {}); + return run; + }), + }; +} + +beforeEach(() => { + localStorage.clear(); + setVisibility('visible'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + Reflect.deleteProperty(navigator, 'locks'); +}); + +describe('claimNow', () => { + it('says yes once', () => { + const key = claimKey('acc', 'uid-1', 'a', FIRE); + expect(claimNow(key, FIRE)).toBe(true); + expect(claimNow(key, FIRE)).toBe(false); + }); + + it('tells occurrences and accounts apart', () => { + expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE)).toBe(true); + expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE + 1000), FIRE + 1000)).toBe(true); + expect(claimNow(claimKey('other', 'uid-1', 'a', FIRE), FIRE)).toBe(true); + }); + + it('forgets what is a day old', () => { + vi.useFakeTimers({ now: FIRE }); + claimNow('old', FIRE - 25 * 3_600_000); + claimNow('new', FIRE); + expect(Object.keys(JSON.parse(localStorage.getItem(CLAIMS_STORAGE_KEY)!))).toEqual(['new']); + }); + + it('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('lets one of two tabs through when their timers run out together', async () => { + Object.defineProperty(navigator, 'locks', { configurable: true, value: fakeLocks() }); + const won = vi.fn(); + claimAlert('k', FIRE, () => won('tab 1')); + claimAlert('k', FIRE, () => won('tab 2')); + await vi.waitFor(() => expect(won).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(won.mock.calls).toEqual([['tab 1']]); + }); + + it('runs at once where there is no Web Lock', () => { + const won = vi.fn(); + claimAlert('k', FIRE, won); + expect(won).toHaveBeenCalledTimes(1); + }); + + it('lets a tab in view go first', () => { + vi.useFakeTimers({ now: FIRE }); + setVisibility('hidden'); + const hidden = vi.fn(); + claimAlert('k', FIRE, hidden); + setVisibility('visible'); + const visible = vi.fn(); + claimAlert('k', FIRE, visible); + expect(visible).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(HIDDEN_GRACE_MS); + expect(hidden).not.toHaveBeenCalled(); + }); + + it('still fires in a hidden tab when no other took it', () => { + vi.useFakeTimers({ now: FIRE }); + setVisibility('hidden'); + const won = vi.fn(); + claimAlert('k', FIRE, won); + expect(won).not.toHaveBeenCalled(); + vi.advanceTimersByTime(HIDDEN_GRACE_MS); + expect(won).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/calendar-reminders/__tests__/runner.test.ts b/lib/calendar-reminders/__tests__/runner.test.ts new file mode 100644 index 000000000..fd45f0ad4 --- /dev/null +++ b/lib/calendar-reminders/__tests__/runner.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { HIDDEN_GRACE_MS } from '../claims'; +import { createReminderRunner, SUMMARY_WAIT_MS, type ReminderRunner, type ReminderRunnerEnv } from '../runner'; + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +interface Tab { + runner: ReminderRunner; + due: ReminderAlert[]; + firedAt: number[]; + missed: MissedBatch[]; + source: boolean[]; + env: ReminderRunnerEnv; + hidden: boolean; + offline: boolean; + muted: boolean; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +const tabs: Tab[] = []; + +function tab(options: Partial> = {}): Tab { + const made = { due: [], firedAt: [], missed: [], source: [], hidden: false, offline: false, muted: false, ...options } as unknown as Tab; + made.env = { + accounts: () => [ACCOUNT], + store: () => store, + params: () => ({ timeZone: 'Europe/Berlin', tasks: true, muted: made.muted }), + hidden: () => made.hidden, + offline: () => made.offline, + onDue: (_account, alerts) => { + made.due.push(...alerts); + made.firedAt.push(...alerts.map(() => Date.now())); + }, + onMissed: async (account) => { + const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' }); + if (batch) made.missed.push(batch); + }, + onSource: (_account, driven) => { + made.source.push(driven); + }, + }; + made.runner = createReminderRunner(made.env); + tabs.push(made); + return made; +} + +async function syncedStore(...events: Record[]): Promise { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); +}); + +afterEach(() => { + for (const made of tabs.splice(0)) made.runner.stop(); + vi.useRealTimers(); +}); + +describe('firing from the store', () => { + it('fires at the moment it is due, with the network gone', async () => { + await syncedStore(event()); + await store.setOnline(false); + server.request = (() => { + throw new Error('A reminder needs no server'); + }) as unknown as FakeCalendarJmap['request']; + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1000); + expect(one.due).toEqual([]); + await vi.advanceTimersByTimeAsync(1000); + expect(one.due.map((alert) => alert.title)).toEqual(['Standup']); + expect(one.firedAt).toEqual([FIRE]); + }); + + it('takes over from the old path once the store has synced, and gives it back when the store fails', async () => { + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(false); + await syncedStore(event()); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(true); + store.failArea = () => true; + one.runner.poke(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(false); + }); + + it('fires in one tab only, when both ask at the same moment', async () => { + await syncedStore(event()); + const [one, two] = [tab(), tab()]; + one.runner.start(); + two.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN); + expect(one.due.length + two.due.length).toBe(1); + }); + + it('leaves it to the tab in view when another is hidden', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + const visible = tab(); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(0); + visible.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS + MIN); + expect(visible.due).toHaveLength(1); + expect(hidden.due).toHaveLength(0); + }); + + it('still fires when only a hidden tab is left', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS); + expect(hidden.due).toHaveLength(1); + }); + + it('notices an event that just arrived, and a snooze just set', async () => { + await syncedStore(); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 2 * MIN); + await syncedStore(event()); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(2 * MIN); + expect(one.firedAt).toEqual([FIRE]); + + await store.area(ACCOUNT, 'reminders.snooze', { key: one.due[0].key, minutes: 5 }); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(5 * MIN); + expect(one.firedAt).toEqual([FIRE, FIRE + 5 * MIN]); + }); + + it('brings a snoozed reminder back after a restart', async () => { + await syncedStore(event()); + const before = tab(); + before.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now()); + await store.area(ACCOUNT, 'reminders.snooze', { key: before.due[0].key, minutes: 10 }); + before.runner.stop(); + await vi.advanceTimersByTimeAsync(MIN); + + const after = tab(); + after.runner.start(); + await vi.advanceTimersByTimeAsync(10 * MIN); + expect(before.due).toHaveLength(1); + expect(after.firedAt).toEqual([FIRE + 10 * MIN]); + expect(after.due[0].snoozedUntil).toBe(FIRE + 10 * MIN); + }); + + it('stays quiet while reminders are off', async () => { + await syncedStore(event()); + const one = tab(); + one.muted = true; + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN); + expect(one.due).toEqual([]); + }); +}); + +describe('after being away', () => { + async function awayFromBothAlerts(): Promise { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = tab(); + before.runner.start(); + await vi.advanceTimersByTimeAsync(0); + before.runner.stop(); + vi.setSystemTime(FIRE + 60 * MIN); + return before; + } + + it('waits a moment for the first sync, then summarises once, not as a burst', async () => { + await awayFromBothAlerts(); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS - 1000); + expect(one.missed).toEqual([]); + await vi.advanceTimersByTimeAsync(1000); + expect(one.due).toEqual([]); + expect(one.missed.map((batch) => batch.alerts.map((alert) => alert.title))).toEqual([['Standup', 'Review']]); + + await vi.advanceTimersByTimeAsync(30 * MIN); + one.runner.wake(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS); + expect(one.missed).toHaveLength(1); + }); + + it('does not wait once the sync is done, and has nothing to wait for offline', async () => { + await awayFromBothAlerts(); + const synced = tab(); + synced.runner.start(); + await vi.advanceTimersByTimeAsync(100); + synced.runner.synced(ACCOUNT); + await vi.advanceTimersByTimeAsync(0); + expect(synced.missed).toHaveLength(1); + }); + + it('gives the summary at once with no network', async () => { + await awayFromBothAlerts(); + const offline = tab({ offline: true }); + offline.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(offline.missed).toHaveLength(1); + }); + + it('gives the summary to one window of two, and neither again after a restart', async () => { + await awayFromBothAlerts(); + const [one, two] = [tab({ offline: true }), tab({ offline: true })]; + one.runner.start(); + two.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.missed.length + two.missed.length).toBe(1); + one.runner.stop(); + two.runner.stop(); + await vi.advanceTimersByTimeAsync(MIN); + const next = tab({ offline: true }); + next.runner.start(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS); + expect(next.missed).toEqual([]); + }); + + it('sees a jump of the clock without being told, as after a sleep', async () => { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(MIN); + // Asleep: timers stood still and the clock did not. + vi.setSystemTime(FIRE + 60 * MIN); + await vi.advanceTimersByTimeAsync(MIN + SUMMARY_WAIT_MS); + expect(one.due).toEqual([]); + expect(one.missed.map((batch) => batch.alerts.length)).toEqual([1 + 1]); + }); + + it('tries a summary again that could not be shown', async () => { + await awayFromBothAlerts(); + const one = tab({ offline: true }); + let refuse = true; + const plain = one.env.onMissed; + one.env.onMissed = async (account) => { + if (!refuse) return plain(account); + const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' }); + await store.area(account, 'reminders.releaseMissed', { batch: batch.batch }); + }; + one.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.missed).toEqual([]); + refuse = false; + await vi.advanceTimersByTimeAsync(5 * MIN); + expect(one.missed).toHaveLength(1); + }); +}); diff --git a/lib/calendar-reminders/channel.ts b/lib/calendar-reminders/channel.ts new file mode 100644 index 000000000..9e32f74ac --- /dev/null +++ b/lib/calendar-reminders/channel.ts @@ -0,0 +1,32 @@ +/** + * Telling the other windows that a reminder has been dealt with, so its toast and system + * notification go there too. What's done is recorded in the store; this only clears what's + * on screen. Only keys cross it, and they say nothing about the event but its uid. + */ + +const CHANNEL_NAME = 'bulwark-calendar-reminders'; + +interface AcknowledgedMessage { + kind: 'acknowledged'; + keys: string[]; +} + +function open(): BroadcastChannel | null { + return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL_NAME); +} + +export function announceAcknowledged(keys: readonly string[]): void { + const channel = open(); + if (!channel) return; + channel.postMessage({ kind: 'acknowledged', keys: [...keys] } satisfies AcknowledgedMessage); + channel.close(); +} + +export function onAcknowledged(listener: (keys: string[]) => void): () => void { + const channel = open(); + if (!channel) return () => {}; + channel.onmessage = (event: MessageEvent) => { + if (event.data?.kind === 'acknowledged' && Array.isArray(event.data.keys)) listener(event.data.keys); + }; + return () => channel.close(); +} diff --git a/lib/calendar-reminders/claims.ts b/lib/calendar-reminders/claims.ts new file mode 100644 index 000000000..069660aea --- /dev/null +++ b/lib/calendar-reminders/claims.ts @@ -0,0 +1,70 @@ +/** + * 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. + */ + +const STORAGE_KEY = 'calendar-alert-claims'; +const LOCK = 'bulwark-calendar-alert-claim'; +const KEEP_MS = 24 * 60 * 60 * 1000; +/** A tab out of view waits, so the one in view is who shows the reminder. */ +export const HIDDEN_GRACE_MS = 2000; + +export function claimKey(account: string | null | undefined, uid: string, alertId: string, fireTimeMs: number): string { + return `${account ?? ''}:${uid}:${alertId}:${fireTimeMs}`; +} + +function readClaims(): Record { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as unknown; + return parsed && typeof parsed === 'object' ? parsed as Record : {}; + } catch { + return {}; + } +} + +/** True for the first to ask. Also what the store path tells the other path with. */ +export function claimNow(key: string, fireTimeMs: number): boolean { + const claims = readClaims(); + if (key in claims) return false; + 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. + } + 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 attempt = () => { + const locks = typeof navigator === 'undefined' ? undefined : navigator.locks; + if (!locks) { + if (claimNow(key, fireTimeMs)) won(); + return; + } + void locks.request(LOCK, () => claimNow(key, fireTimeMs)).then((claimed) => { + if (claimed) won(); + }); + }; + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') setTimeout(attempt, HIDDEN_GRACE_MS); + else attempt(); +} + +/** A sign-out: the next person starts with a clean slate. */ +export function clearClaims(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Nothing was kept, then. + } +} + +export const CLAIMS_STORAGE_KEY = STORAGE_KEY; diff --git a/lib/calendar-reminders/runner.ts b/lib/calendar-reminders/runner.ts new file mode 100644 index 000000000..26bb9a569 --- /dev/null +++ b/lib/calendar-reminders/runner.ts @@ -0,0 +1,156 @@ +/** + * 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. + * + * Between checks it sleeps until the store's next fire time, and looks in every few + * minutes in case something changed with nobody saying so. + */ + +import type { StoreBackend } from '@/lib/store/client/api'; +import type { ReminderAlert, TickParams, TickResult } from '@/lib/store/reminders/ledger'; +import { HIDDEN_GRACE_MS } from './claims'; + +const REFRESH_MS = 5 * 60_000; +/** Timers stand still while a machine sleeps. The clock doesn't. */ +const CLOCK_WATCH_MS = 60_000; +const CLOCK_JUMP_MS = 2 * CLOCK_WATCH_MS; +/** + * After a start or a wake, how long a summary waits for the first sync, so it leaves out + * what was deleted elsewhere in the meantime. Reminders that are due don't wait. + */ +export const SUMMARY_WAIT_MS = 20_000; + +export interface ReminderRunnerEnv { + /** The local accounts with a store right now. */ + accounts(): string[]; + store(): Pick; + params(): TickParams; + /** Out of view: another tab in view should get to go first. */ + hidden(): boolean; + offline(): boolean; + /** Runs in the one tab that got these. */ + onDue(account: string, alerts: ReminderAlert[]): void; + /** There's a summary to give. Resolves when that's been tried. */ + onMissed(account: string): Promise; + /** Whether this account's reminders come from the store now, or the app's old path keeps them. */ + onSource(account: string, driven: boolean): void; +} + +export interface ReminderRunner { + start(): void; + stop(): void; + /** Check now: the calendar changed, a snooze was set. */ + poke(): void; + /** Back from sleep, or back online. */ + wake(): void; + /** The store finished a sync for this account. */ + synced(account: string): void; +} + +export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { + let timer: ReturnType | null = null; + let clockWatch: ReturnType | null = null; + let lastWatch = 0; + let running = false; + let again = false; + let stopped = true; + let summaryAt = 0; + const presenting = new Set(); + const known = new Set(); + + const schedule = (at: number) => { + if (timer) clearTimeout(timer); + const wait = Math.max(at - Date.now(), 0) + (env.hidden() ? HIDDEN_GRACE_MS : 0); + timer = setTimeout(() => void run(), wait); + }; + + const check = async (account: string): Promise => { + let result: TickResult; + try { + result = await env.store().area(account, 'reminders.tick', env.params()); + } catch { + // The store is out of reach: the old path takes these over until it's back. + env.onSource(account, false); + 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; + 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); + const at = await check(account); + if (at !== null) next = Math.min(next, at); + } + running = false; + if (stopped) return; + if (again) { + again = false; + void run(); + 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); + void run(); + }, + 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() { + void run(); + }, + wake() { + summaryAt = Date.now() + SUMMARY_WAIT_MS; + void run(); + }, + synced() { + summaryAt = 0; + void run(); + }, + }; +} diff --git a/lib/calendar-reminders/state.ts b/lib/calendar-reminders/state.ts new file mode 100644 index 000000000..109df52ee --- /dev/null +++ b/lib/calendar-reminders/state.ts @@ -0,0 +1,55 @@ +/** + * What the reminder code shares across the page, kept free of the rest of the app so + * useCalendarAlerts can read it without pulling in the local store. + */ + +import { create } from 'zustand'; +import type { ReminderAlert } from '@/lib/store/reminders/ledger'; + +interface ReminderSourceState { + /** Local accounts whose reminders come from the store: useCalendarAlerts leaves those alone. */ + driven: Record; + setDriven: (account: string, driven: boolean) => void; +} + +export const useReminderSource = create()((set) => ({ + driven: {}, + setDriven: (account, driven) => set((state) => { + if (!!state.driven[account] === driven) return state; + const next = { ...state.driven }; + if (driven) next[account] = true; + else delete next[account]; + return { driven: next }; + }), +})); + +interface MissedRemindersState { + account: string | null; + alerts: ReminderAlert[]; + show: (account: string, alerts: ReminderAlert[]) => void; + close: () => void; +} + +/** The summary dialog's contents. Empty: it's closed. */ +export const useMissedReminders = create()((set) => ({ + account: null, + alerts: [], + show: (account, alerts) => set({ account, alerts }), + close: () => set({ account: null, alerts: [] }), +})); + +let poke: (() => void) | null = null; + +/** The running check, so something done from a toast takes effect at once. */ +export function setReminderPoke(next: (() => void) | null): void { + poke = next; +} + +export function pokeReminders(): void { + poke?.(); +} + +/** The zone that reads a floating event: the setting's, or the system's on `auto`. */ +export function reminderTimeZone(setting: string | undefined): string { + return setting && setting !== 'auto' ? setting : Intl.DateTimeFormat().resolvedOptions().timeZone; +} diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts new file mode 100644 index 000000000..538669c1b --- /dev/null +++ b/lib/calendar-reminders/system-notification.ts @@ -0,0 +1,70 @@ +/** + * 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; +} + +function tagOf(key: string): string { + return `calendar-reminder:${key}`; +} + +function permitted(): boolean { + return typeof Notification !== 'undefined' && Notification.permission === 'granted'; +} + +async function registration(): Promise { + try { + return await navigator.serviceWorker?.getRegistration(); + } catch { + return undefined; + } +} + +/** In front of the user the in-app toast says it already. */ +export function wantsSystemNotification(): boolean { + if (!permitted() || typeof document === 'undefined') return false; + return document.visibilityState === 'hidden' || !document.hasFocus(); +} + +export async function showSystemReminder(reminder: SystemReminder, openUrl: string): Promise { + if (!wantsSystemNotification()) return; + const options = { body: reminder.body, tag: tagOf(reminder.key), silent: reminder.silent }; + try { + const worker = await registration(); + if (worker) { + await worker.showNotification(reminder.title, { ...options, data: { kind: 'calendar-reminder' } }); + return; + } + const shown = new Notification(reminder.title, options); + shown.onclick = () => { + window.focus(); + window.location.href = openUrl; + shown.close(); + }; + } catch (error) { + console.warn('A system notification could not be shown:', error); + } +} + +/** Dealt with elsewhere: it shouldn't stay in the notification list. */ +export async function closeSystemReminders(keys: readonly string[]): Promise { + if (!permitted() || keys.length === 0) return; + const worker = await registration(); + if (!worker) return; + try { + for (const key of keys) { + for (const shown of await worker.getNotifications({ tag: tagOf(key) })) shown.close(); + } + } catch { + // Nothing that matters is left behind. + } +} diff --git a/lib/desktop/__tests__/reminder-notifications.test.ts b/lib/desktop/__tests__/reminder-notifications.test.ts new file mode 100644 index 000000000..1a19e29d2 --- /dev/null +++ b/lib/desktop/__tests__/reminder-notifications.test.ts @@ -0,0 +1,239 @@ +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, + 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 }) } })); + +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', + missedTitle: (count) => `${count} missed`, + missedMore: (count) => `and ${count} more`, +}; + +function alert(partial: Partial = {}): ReminderAlert { + return { + key: `j1:uid-1:a:${FIRE}`, jmap: 'j1', uid: 'uid-1', kind: 'event', baseEventId: 'e1', recurrenceId: null, alertId: 'a', fireTimeMs: FIRE, + title: 'Secret board meeting', start: '2026-11-10T10:00:00', startMs: START, joinUrl: null, timeZone: 'Europe/Berlin', calendarId: 'cal1', calendarName: 'Personal', + ...partial, + }; +} + +const focus = vi.fn(); +const openExternal = vi.fn(); +const notify = vi.fn(async () => ({ shown: true, playSound: false })); + +function setPlatform(platform: string) { + window.bulwarkDesktop = { version: 1, platform, windows: { focus }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +const env = { leaveFor: vi.fn(), push: vi.fn() }; + +beforeEach(() => { + vi.clearAllMocks(); + policy.pushWithoutContent = false; + activeAccountId = ACCOUNT; + setAppLockCheck(null); + setPlatform('win32'); + useMissedReminders.getState().close(); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.useRealTimers(); +}); + +async function contextFor(reminder: ReminderAlert): Promise { + return (await buildReminderNotification(ACCOUNT, reminder, labels, false)).context; +} + +describe('what a reminder says', () => { + it('offers Join only when the event has a link', async () => { + const without = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(without.actions!.map((action) => action.id)).toEqual(['open', 'snooze-5', 'snooze-10', 'snooze-15']); + const withLink = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, false); + expect(withLink.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']); + }); + + 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('id', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT); + expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Dentist', body: 'In 5 min', context: '{"cal":true}' })); + setAppLockCheck(() => true); + notifyWithoutStore('id2', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT); + expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'GENERIC', body: '' })); + }); +}); + +describe('what its buttons do', () => { + /** A reminder really on record in the store. */ + async function fired(partial: Record = {}): Promise { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, ...partial }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE); + return (await store.area(ACCOUNT, 'reminders.tick', params)).due[0]; + } + + it('snoozes from the store with the window left hidden', async () => { + const reminder = await fired(); + expect(await handleReminderCommand('snooze-10', await contextFor(reminder), env)).toBe(true); + expect(focus).not.toHaveBeenCalled(); + expect(env.leaveFor).not.toHaveBeenCalled(); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 9 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + vi.setSystemTime(FIRE + 10 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toHaveLength(1); + }); + + it('refuses a snooze length no button offers', async () => { + const reminder = await fired(); + await handleReminderCommand('snooze-999', await contextFor(reminder), env); + expect(focus).toHaveBeenCalled(); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 16 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + }); + + it('joins through the OS from what the store holds, with the window left hidden', async () => { + const reminder = await fired({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } }); + await handleReminderCommand('join', await contextFor(reminder), env); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup'); + expect(focus).not.toHaveBeenCalled(); + }); + + it('opens the event in the account on screen', async () => { + const reminder = await fired(); + await handleReminderCommand('open', await contextFor(reminder), env); + expect(focus).toHaveBeenCalled(); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(env.push).toHaveBeenCalledWith('/calendar/event/e1?calendar=cal1'); + }); + + it('opens only the calendar for an account that is not on screen', async () => { + const reminder = await fired(); + const context = await contextFor(reminder); + activeAccountId = 'someone-else'; + await handleReminderCommand('open', context, env); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(env.push).not.toHaveBeenCalled(); + }); + + it('touches nothing when the slot has gone to another login since', async () => { + const reminder = await fired(); + const context = JSON.parse(await contextFor(reminder)) as { login: string }; + context.login = 'not-this-login'; + await handleReminderCommand('snooze-5', JSON.stringify(context), env); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 5 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + }); + + it('opens the summary from a click on its toast, even after a restart', async () => { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'One', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + server.addEvent({ id: 'e2', uid: 'uid-2', title: 'Two', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE + 60 * MIN); + await store.area(ACCOUNT, 'reminders.tick', params); + const batch = await store.area<{ batch: number; alerts: ReminderAlert[] }>(ACCOUNT, 'reminders.takeMissed', params); + const toast = await buildMissedNotification(ACCOUNT, batch, labels); + + await handleReminderCommand('open', toast.context, env); + expect(focus).toHaveBeenCalled(); + expect(useMissedReminders.getState().alerts.map((a) => a.title)).toEqual(['One', 'Two']); + expect(useMissedReminders.getState().account).toBe(ACCOUNT); + }); + + it('goes to the calendar for a store-less reminder, and leaves mail toasts alone', async () => { + expect(await handleReminderCommand('open', JSON.stringify({ cal: true }), env)).toBe(true); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(await handleReminderCommand('open', JSON.stringify({ emailId: 'm1', slot: 3 }), env)).toBe(false); + expect(await handleReminderCommand('open', '', env)).toBe(false); + expect(await handleReminderCommand('open', '{broken', env)).toBe(false); + }); +}); diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index 8c7332225..3a6fa8703 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -8,6 +8,7 @@ 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'; @@ -89,6 +90,7 @@ function runAction(request: NotificationActionRequest): Promise { + if (await handleReminderCommand(action, rawContext, env)) return; const context = parseContext(rawContext); if (action === 'reply') { await openFromNotification(context, env, { text: reply ?? '' }); diff --git a/lib/desktop/reminder-notifications.ts b/lib/desktop/reminder-notifications.ts new file mode 100644 index 000000000..3979c1d96 --- /dev/null +++ b/lib/desktop/reminder-notifications.ts @@ -0,0 +1,218 @@ +/** + * 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 } from '@/lib/desktop/types'; +import { devicesForAccount } from '@/lib/device-policy'; +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; + missedTitle: (count: number) => string; + missedMore: (count: number) => string; +} + +export interface ReminderToastContext { + /** The reminder. */ + r?: string; + /** The summary. */ + rb?: number; + /** No store to ask: the click only goes to the calendar. */ + cal?: boolean; + slot?: number; + login?: string; +} + +async function loginOf(account: string): Promise> { + const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account); + if (!entry) return {}; + return { slot: entry.cookieSlot, login: await loginFingerprint(entry.serverUrl, entry.username) }; +} + +function hideContent(account: string): boolean { + if (isAppLocked()) return true; + try { + return devicesForAccount(account).pushWithoutContent; + } catch { + return true; + } +} + +function reminderId(alert: ReminderAlert): string { + // Main drops an id it saw in the last few minutes, and a snooze can be shorter. + return `reminder:${alert.key}${alert.snoozedUntil ? `:${alert.snoozedUntil}` : ''}`; +} + +export async function buildReminderNotification(account: string, alert: ReminderAlert, labels: ReminderLabels, sound: boolean): Promise { + const base = { group: 'reminders', kind: 'reminder' as const, sound }; + // Not even the uid. Two at the same moment are one "you have a reminder". + if (isAppLocked()) return { ...base, id: `reminder-locked:${alert.fireTimeMs}`, title: labels.generic, body: '', context: '' }; + const context: ReminderToastContext = { r: alert.key, ...(await loginOf(account)) }; + const short = getDesktopBridge()?.platform === 'win32'; + const actions = [ + ...(alert.joinUrl ? [{ id: JOIN, label: labels.join }] : []), + { id: OPEN, label: labels.open }, + ...SNOOZE_MINUTES.map((minutes) => ({ id: `${SNOOZE_PREFIX}${minutes}`, label: short ? labels.snoozeShort(minutes) : labels.snooze(minutes) })), + ]; + const hidden = hideContent(account); + return { + ...base, + id: reminderId(alert), + title: hidden ? labels.generic : alert.title || labels.untitled, + body: hidden ? '' : labels.when(alert), + context: JSON.stringify(context), + actions, + }; +} + +/** The first few titles of a summary, for a place too small for all of them. */ +export function missedBody(alerts: readonly ReminderAlert[], labels: Pick): string { + const named = alerts.slice(0, SUMMARY_TITLES).map((alert) => alert.title || labels.untitled).join(', '); + const more = alerts.length - SUMMARY_TITLES; + return more > 0 ? `${named}, ${labels.missedMore(more)}` : named; +} + +export async function buildMissedNotification(account: string, batch: MissedBatch, labels: ReminderLabels): Promise { + const context: ReminderToastContext = { rb: batch.batch, ...(await loginOf(account)) }; + return { + id: `reminder-missed:${batch.batch}`, + group: 'reminders', + kind: 'reminder', + title: labels.missedTitle(batch.alerts.length), + body: hideContent(account) ? '' : missedBody(batch.alerts, labels), + context: JSON.stringify(context), + }; +} + +/** Without a store: says so, with nothing to snooze. A click goes to the calendar. */ +export function notifyWithoutStore(id: string, title: string, body: string, labels: { generic: string }, account: string | null): void { + const hidden = isAppLocked() || !account || hideContent(account); + void getDesktopBridge()?.os?.notify({ + id, + group: 'reminders', + kind: 'reminder', + title: hidden ? labels.generic : title, + body: hidden ? '' : body, + context: JSON.stringify({ cal: true } satisfies ReminderToastContext), + }); +} + +async function accountOf(context: ReminderToastContext): Promise { + if (context.slot === undefined || !context.login) return null; + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot); + if (!entry || (await loginFingerprint(entry.serverUrl, entry.username)) !== context.login) return null; + return entry.id; +} + +/** Goes to the event when it's in the account on screen, otherwise to the calendar. */ +export function calendarPathFor(account: string, alert: ReminderAlert | null): string | null { + if (!alert || alert.kind !== 'event' || useAuthStore.getState().activeAccountId !== account) return null; + const query = alert.calendarId ? `?calendar=${encodeURIComponent(alert.calendarId)}` : ''; + return `/calendar/event/${encodeURIComponent(alert.baseEventId)}${query}`; +} + +export function joinMeeting(url: string): void { + const shell = getDesktopBridge()?.shell; + if (shell) shell.openExternal(url); + else window.open(url, '_blank', 'noopener,noreferrer'); +} + +export function dismissReminders(account: string, keys: string[]): void { + announceAcknowledged(keys); + void getStore().area(account, 'reminders.dismiss', { keys }).catch(() => {}); +} + +export async function snoozeReminder(account: string, key: string, minutes: number): Promise { + announceAcknowledged([key]); + await getStore().area(account, 'reminders.snooze', { key, minutes }); + pokeReminders(); +} + +export interface ReminderCommandEnv { + leaveFor: (path: '/calendar') => void | Promise; + push: (path: string) => void; +} + +function parse(raw: string): ReminderToastContext | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as ReminderToastContext; + return parsed && typeof parsed === 'object' && (parsed.r !== undefined || parsed.rb !== undefined || parsed.cal) ? parsed : null; + } catch { + return null; + } +} + +function snoozeMinutes(action: string): number | null { + if (!action.startsWith(SNOOZE_PREFIX)) return null; + const minutes = Number(action.slice(SNOOZE_PREFIX.length)); + return (SNOOZE_MINUTES as readonly number[]).includes(minutes) ? minutes : null; +} + +/** Whether this click was a reminder's. Buttons that only do something don't bring the window up. */ +export async function handleReminderCommand(action: string, rawContext: string, env: ReminderCommandEnv): Promise { + const context = parse(rawContext); + if (!context) return false; + const account = await accountOf(context); + const show = () => getDesktopBridge()?.windows?.focus?.(); + + if (context.rb !== undefined) { + show(); + const alerts = account ? await getStore().area(account, 'reminders.batch', { batch: context.rb }).catch(() => []) : []; + if (account && alerts.length > 0) useMissedReminders.getState().show(account, alerts); + else await env.leaveFor('/calendar'); + return true; + } + + const key = context.r; + const minutes = snoozeMinutes(action); + if (minutes !== null && account && key) { + await snoozeReminder(account, key, minutes); + return true; + } + + const alert = account && key ? await getStore().area(account, 'reminders.get', { key }).catch(() => null) : null; + if (action === JOIN && alert?.joinUrl) { + joinMeeting(alert.joinUrl); + dismissReminders(account!, [alert.key]); + return true; + } + + // Open, a click on the toast, or a button that can't be carried out any more. + show(); + await env.leaveFor('/calendar'); + const path = account ? calendarPathFor(account, alert) : null; + if (path) env.push(path); + if (account && alert) dismissReminders(account, [alert.key]); + return true; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 696ab3183..249a45cba 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -235,6 +235,8 @@ export interface DesktopOsNotification { 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 { diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index d71993589..77122dd0e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -30,6 +30,7 @@ import { labelsPolicyFor } from '@/lib/outgoing/classification'; import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards'; import { emitPushStateChange } from '@/lib/push-events'; import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; +import { emitStoreEvent } from '@/lib/offline/store-events'; import { STORAGE_ERROR_AREA } from '@/lib/store/area'; import type { DraftError } from '@/lib/store/drafts/pending'; import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api'; @@ -197,6 +198,7 @@ const storeHooks: StorePageHooks = { notify({ kind: 'store-reset' }); }, onEvent(event) { + emitStoreEvent(event); if (event.type === 'created' && event.collection === 'Email') { const client = onlineClientFor(event.account); if (client) reportStoreCreatedMail(client, event.jmap, event.ids); diff --git a/lib/offline/store-events.ts b/lib/offline/store-events.ts new file mode 100644 index 000000000..b857ec351 --- /dev/null +++ b/lib/offline/store-events.ts @@ -0,0 +1,18 @@ +/** The store's events, for the parts of the page that aren't offline mode itself. */ + +import type { StoreEvent } from '@/lib/store/area'; + +type Listener = (event: StoreEvent) => void; + +const listeners = new Set(); + +export function addStoreEventListener(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function emitStoreEvent(event: StoreEvent): void { + for (const listener of [...listeners]) listener(event); +} diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 531c99f7f..2400d48f3 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -21,6 +21,7 @@ const SIGNED_OUT_STORAGE_KEYS = [ 'contact-storage', 'calendar-storage', 'calendar-notification-storage', + 'calendar-alert-claims', // Open tabs name messages and their subjects. 'pro-tabs', 'search-history-storage', diff --git a/locales/ar/common.json b/locales/ar/common.json index 169c04ec5..05c769ae3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "نسخ كملف", "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.", "copy_failed": "تعذّر نسخ الملف." + }, + "desktopReminders": { + "generic_title": "تذكير", + "snooze": "تأجيل {count} د", + "snooze_short": "{count} د", + "open": "فتح", + "join": "انضمام", + "missed_title": "{count, plural, zero {لا تذكيرات فائتة} one {تذكير فائت واحد} two {تذكيران فائتان} few {# تذكيرات فائتة} many {# تذكيرًا فائتًا} other {# تذكير فائت}}", + "missed_more": "و{count} أخرى", + "missed_show": "عرض", + "missed_dialog_title": "التذكيرات الفائتة", + "missed_dialog_description": "حان موعد هذه التذكيرات بينما كان التطبيق مغلقًا أو الكمبيوتر في وضع السكون أو الإشعارات متوقفة مؤقتًا.", + "open_calendar": "فتح التقويم", + "done": "تم" } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 5dd448d8e..b3a56b969 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copia com a fitxer", "copied": "Fitxer copiat. El podeu enganxar en una carpeta.", "copy_failed": "No s'ha pogut copiar el fitxer." + }, + "desktopReminders": { + "generic_title": "Recordatori", + "snooze": "Posposa {count} min", + "snooze_short": "{count} min", + "open": "Obre", + "join": "Uneix-te", + "missed_title": "{count, plural, one {# recordatori perdut} other {# recordatoris perduts}}", + "missed_more": "i {count} més", + "missed_show": "Mostra", + "missed_dialog_title": "Recordatoris perduts", + "missed_dialog_description": "Van vèncer mentre l'aplicació estava tancada, l'ordinador en repòs o les notificacions en pausa.", + "open_calendar": "Obre el calendari", + "done": "Fet" } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 567020087..626c9d150 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopírovat jako soubor", "copied": "Soubor zkopírován. Můžete ho vložit do složky.", "copy_failed": "Soubor se nepodařilo zkopírovat." + }, + "desktopReminders": { + "generic_title": "Připomenutí", + "snooze": "Odložit o {count} min", + "snooze_short": "{count} min", + "open": "Otevřít", + "join": "Připojit se", + "missed_title": "{count, plural, one {# zmeškané připomenutí} few {# zmeškaná připomenutí} many {# zmeškaného připomenutí} other {# zmeškaných připomenutí}}", + "missed_more": "a další ({count})", + "missed_show": "Zobrazit", + "missed_dialog_title": "Zmeškaná připomenutí", + "missed_dialog_description": "Nastala, když byla aplikace zavřená, počítač v režimu spánku nebo oznámení pozastavená.", + "open_calendar": "Otevřít kalendář", + "done": "Hotovo" } } diff --git a/locales/da/common.json b/locales/da/common.json index 352cd2805..5c9d9a580 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopiér som fil", "copied": "Filen er kopieret. Du kan indsætte den i en mappe.", "copy_failed": "Filen kunne ikke kopieres." + }, + "desktopReminders": { + "generic_title": "Påmindelse", + "snooze": "Udsæt {count} min.", + "snooze_short": "{count} min.", + "open": "Åbn", + "join": "Deltag", + "missed_title": "{count, plural, one {# overset påmindelse} other {# oversete påmindelser}}", + "missed_more": "og {count} mere", + "missed_show": "Vis", + "missed_dialog_title": "Oversete påmindelser", + "missed_dialog_description": "De forfaldt, mens appen var lukket, computeren sov, eller notifikationer var sat på pause.", + "open_calendar": "Åbn kalender", + "done": "Færdig" } } diff --git a/locales/de/common.json b/locales/de/common.json index 65fa8c8dd..ea62fa855 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Als Datei kopieren", "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.", "copy_failed": "Die Datei konnte nicht kopiert werden." + }, + "desktopReminders": { + "generic_title": "Erinnerung", + "snooze": "{count} Min. schlummern", + "snooze_short": "{count} Min.", + "open": "Öffnen", + "join": "Teilnehmen", + "missed_title": "{count, plural, one {# verpasste Erinnerung} other {# verpasste Erinnerungen}}", + "missed_more": "und {count} weitere", + "missed_show": "Anzeigen", + "missed_dialog_title": "Verpasste Erinnerungen", + "missed_dialog_description": "Diese wurden fällig, während die App geschlossen war, der Computer im Ruhezustand war oder Benachrichtigungen pausiert waren.", + "open_calendar": "Kalender öffnen", + "done": "Fertig" } } diff --git a/locales/en/common.json b/locales/en/common.json index 4512e406e..7857501d3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copy as file", "copied": "File copied. You can paste it into a folder.", "copy_failed": "The file could not be copied." + }, + "desktopReminders": { + "generic_title": "Reminder", + "snooze": "Snooze {count} min", + "snooze_short": "{count} min", + "open": "Open", + "join": "Join", + "missed_title": "{count, plural, one {# reminder you missed} other {# reminders you missed}}", + "missed_more": "and {count} more", + "missed_show": "Show", + "missed_dialog_title": "Reminders you missed", + "missed_dialog_description": "These came due while the app was closed, the computer was asleep or notifications were paused.", + "open_calendar": "Open calendar", + "done": "Done" } } diff --git a/locales/es/common.json b/locales/es/common.json index 6ed72a0f4..7542536a1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copiar como archivo", "copied": "Archivo copiado. Puedes pegarlo en una carpeta.", "copy_failed": "No se pudo copiar el archivo." + }, + "desktopReminders": { + "generic_title": "Recordatorio", + "snooze": "Posponer {count} min", + "snooze_short": "{count} min", + "open": "Abrir", + "join": "Unirse", + "missed_title": "{count, plural, one {# recordatorio perdido} other {# recordatorios perdidos}}", + "missed_more": "y {count} más", + "missed_show": "Mostrar", + "missed_dialog_title": "Recordatorios perdidos", + "missed_dialog_description": "Vencieron mientras la aplicación estaba cerrada, el ordenador en reposo o las notificaciones en pausa.", + "open_calendar": "Abrir calendario", + "done": "Listo" } } diff --git a/locales/et/common.json b/locales/et/common.json index d0d6356b6..40abb6d20 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopeeri failina", "copied": "Fail on kopeeritud. Saad selle kausta kleepida.", "copy_failed": "Faili ei saanud kopeerida." + }, + "desktopReminders": { + "generic_title": "Meeldetuletus", + "snooze": "Lükka {count} min edasi", + "snooze_short": "{count} min", + "open": "Ava", + "join": "Liitu", + "missed_title": "{count, plural, one {# kasutamata meeldetuletus} other {# kasutamata meeldetuletust}}", + "missed_more": "ja veel {count}", + "missed_show": "Näita", + "missed_dialog_title": "Kasutamata meeldetuletused", + "missed_dialog_description": "Need saabusid, kui rakendus oli suletud, arvuti puhkas või teavitused olid peatatud.", + "open_calendar": "Ava kalender", + "done": "Valmis" } } diff --git a/locales/fa/common.json b/locales/fa/common.json index de207bb10..8d5df86ea 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "رونوشت به‌صورت پرونده", "copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.", "copy_failed": "رونوشت پرونده ممکن نشد." + }, + "desktopReminders": { + "generic_title": "یادآوری", + "snooze": "تعویق {count} دقیقه", + "snooze_short": "{count} دقیقه", + "open": "باز کردن", + "join": "پیوستن", + "missed_title": "{count, plural, one {# یادآوری از دست‌رفته} other {# یادآوری از دست‌رفته}}", + "missed_more": "و {count} مورد دیگر", + "missed_show": "نمایش", + "missed_dialog_title": "یادآوری‌های از دست‌رفته", + "missed_dialog_description": "این یادآوری‌ها زمانی رسیدند که برنامه بسته بود، رایانه در خواب بود یا اعلان‌ها متوقف بودند.", + "open_calendar": "باز کردن تقویم", + "done": "تمام" } } diff --git a/locales/fr/common.json b/locales/fr/common.json index b51367064..93edc4bd6 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copier en tant que fichier", "copied": "Fichier copié. Vous pouvez le coller dans un dossier.", "copy_failed": "Le fichier n'a pas pu être copié." + }, + "desktopReminders": { + "generic_title": "Rappel", + "snooze": "Reporter de {count} min", + "snooze_short": "{count} min", + "open": "Ouvrir", + "join": "Rejoindre", + "missed_title": "{count, plural, one {# rappel manqué} other {# rappels manqués}}", + "missed_more": "et {count} de plus", + "missed_show": "Afficher", + "missed_dialog_title": "Rappels manqués", + "missed_dialog_description": "Ils sont arrivés à échéance alors que l'application était fermée, l'ordinateur en veille ou les notifications en pause.", + "open_calendar": "Ouvrir le calendrier", + "done": "Terminé" } } diff --git a/locales/he/common.json b/locales/he/common.json index 613e36392..6d227dd04 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6592,5 +6592,19 @@ "copy_as_file": "העתקה כקובץ", "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.", "copy_failed": "לא ניתן להעתיק את הקובץ." + }, + "desktopReminders": { + "generic_title": "תזכורת", + "snooze": "דחייה ב-{count} דק'", + "snooze_short": "{count} דק'", + "open": "פתיחה", + "join": "הצטרפות", + "missed_title": "{count, plural, one {תזכורת אחת שהוחמצה} two {# תזכורות שהוחמצו} other {# תזכורות שהוחמצו}}", + "missed_more": "ועוד {count}", + "missed_show": "הצגה", + "missed_dialog_title": "תזכורות שהוחמצו", + "missed_dialog_description": "הן הגיעו כשהאפליקציה הייתה סגורה, המחשב היה במצב שינה או שההתראות הושהו.", + "open_calendar": "פתיחת היומן", + "done": "סיום" } } diff --git a/locales/hu/common.json b/locales/hu/common.json index b72bcfc0d..1794a8340 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Másolás fájlként", "copied": "Fájl másolva. Beillesztheti egy mappába.", "copy_failed": "A fájlt nem sikerült másolni." + }, + "desktopReminders": { + "generic_title": "Emlékeztető", + "snooze": "Halasztás {count} perccel", + "snooze_short": "{count} perc", + "open": "Megnyitás", + "join": "Csatlakozás", + "missed_title": "{count, plural, one {# elmulasztott emlékeztető} other {# elmulasztott emlékeztető}}", + "missed_more": "és még {count}", + "missed_show": "Megjelenítés", + "missed_dialog_title": "Elmulasztott emlékeztetők", + "missed_dialog_description": "Akkor jártak le, amikor az alkalmazás be volt zárva, a számítógép alvó állapotban volt, vagy az értesítések szüneteltek.", + "open_calendar": "Naptár megnyitása", + "done": "Kész" } } diff --git a/locales/it/common.json b/locales/it/common.json index 421714d43..aaa5bc1ed 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copia come file", "copied": "File copiato. Puoi incollarlo in una cartella.", "copy_failed": "Impossibile copiare il file." + }, + "desktopReminders": { + "generic_title": "Promemoria", + "snooze": "Rimanda di {count} min", + "snooze_short": "{count} min", + "open": "Apri", + "join": "Partecipa", + "missed_title": "{count, plural, one {# promemoria perso} other {# promemoria persi}}", + "missed_more": "e altri {count}", + "missed_show": "Mostra", + "missed_dialog_title": "Promemoria persi", + "missed_dialog_description": "Sono scattati mentre l'app era chiusa, il computer in sospensione o le notifiche in pausa.", + "open_calendar": "Apri calendario", + "done": "Fatto" } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 1b72f2fb8..b072304ec 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "ファイルとしてコピー", "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。", "copy_failed": "ファイルをコピーできませんでした。" + }, + "desktopReminders": { + "generic_title": "リマインダー", + "snooze": "{count} 分後に再通知", + "snooze_short": "{count} 分", + "open": "開く", + "join": "参加", + "missed_title": "{count, plural, other {見逃したリマインダー # 件}}", + "missed_more": "ほか {count} 件", + "missed_show": "表示", + "missed_dialog_title": "見逃したリマインダー", + "missed_dialog_description": "アプリが閉じていた間、コンピューターがスリープしていた間、または通知を一時停止していた間に期限が来たものです。", + "open_calendar": "カレンダーを開く", + "done": "完了" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index ce7ac48d8..9b06f3d24 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "파일로 복사", "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.", "copy_failed": "파일을 복사할 수 없습니다." + }, + "desktopReminders": { + "generic_title": "알림", + "snooze": "{count}분 후 다시 알림", + "snooze_short": "{count}분", + "open": "열기", + "join": "참가", + "missed_title": "{count, plural, other {놓친 알림 #개}}", + "missed_more": "외 {count}개", + "missed_show": "보기", + "missed_dialog_title": "놓친 알림", + "missed_dialog_description": "앱이 닫혀 있거나 컴퓨터가 절전 상태이거나 알림이 일시 중지된 동안 시간이 된 알림입니다.", + "open_calendar": "캘린더 열기", + "done": "완료" } } diff --git a/locales/lv/common.json b/locales/lv/common.json index b45fabbb4..891aeb6e2 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopēt kā failu", "copied": "Fails nokopēts. Varat to ielīmēt mapē.", "copy_failed": "Failu neizdevās nokopēt." + }, + "desktopReminders": { + "generic_title": "Atgādinājums", + "snooze": "Atlikt par {count} min", + "snooze_short": "{count} min", + "open": "Atvērt", + "join": "Pievienoties", + "missed_title": "{count, plural, zero {# nokavētu atgādinājumu} one {# nokavēts atgādinājums} other {# nokavēti atgādinājumi}}", + "missed_more": "un vēl {count}", + "missed_show": "Rādīt", + "missed_dialog_title": "Nokavētie atgādinājumi", + "missed_dialog_description": "Tie pienāca, kamēr lietotne bija aizvērta, dators bija miega režīmā vai paziņojumi bija apturēti.", + "open_calendar": "Atvērt kalendāru", + "done": "Gatavs" } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 243f02639..2a182da0e 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Файл болгож хуулах", "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.", "copy_failed": "Файлыг хуулж чадсангүй." + }, + "desktopReminders": { + "generic_title": "Сануулга", + "snooze": "{count} минут хойшлуулах", + "snooze_short": "{count} мин", + "open": "Нээх", + "join": "Нэгдэх", + "missed_title": "{count, plural, one {Алдсан # сануулга} other {Алдсан # сануулга}}", + "missed_more": "бас {count}", + "missed_show": "Харуулах", + "missed_dialog_title": "Алдсан сануулгууд", + "missed_dialog_description": "Апп хаалттай, компьютер унтсан эсвэл мэдэгдэл түр зогссон үед хугацаа нь болсон.", + "open_calendar": "Хуанли нээх", + "done": "Болсон" } } diff --git a/locales/nb/common.json b/locales/nb/common.json index f2f0c0de7..3573c7773 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopier som fil", "copied": "Filen er kopiert. Du kan lime den inn i en mappe.", "copy_failed": "Filen kunne ikke kopieres." + }, + "desktopReminders": { + "generic_title": "Påminnelse", + "snooze": "Utsett {count} min", + "snooze_short": "{count} min", + "open": "Åpne", + "join": "Bli med", + "missed_title": "{count, plural, one {# tapt påminnelse} other {# tapte påminnelser}}", + "missed_more": "og {count} til", + "missed_show": "Vis", + "missed_dialog_title": "Tapte påminnelser", + "missed_dialog_description": "De forfalt mens appen var lukket, datamaskinen sov eller varsler var satt på pause.", + "open_calendar": "Åpne kalender", + "done": "Ferdig" } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 6ab31ef9b..17329f729 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopiëren als bestand", "copied": "Bestand gekopieerd. Je kunt het in een map plakken.", "copy_failed": "Het bestand kon niet worden gekopieerd." + }, + "desktopReminders": { + "generic_title": "Herinnering", + "snooze": "{count} min uitstellen", + "snooze_short": "{count} min", + "open": "Openen", + "join": "Deelnemen", + "missed_title": "{count, plural, one {# gemiste herinnering} other {# gemiste herinneringen}}", + "missed_more": "en nog {count}", + "missed_show": "Tonen", + "missed_dialog_title": "Gemiste herinneringen", + "missed_dialog_description": "Deze kwamen op terwijl de app gesloten was, de computer sliep of meldingen gepauzeerd waren.", + "open_calendar": "Agenda openen", + "done": "Klaar" } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 4590d48b3..6b50e2feb 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopiuj jako plik", "copied": "Plik skopiowany. Możesz wkleić go do folderu.", "copy_failed": "Nie udało się skopiować pliku." + }, + "desktopReminders": { + "generic_title": "Przypomnienie", + "snooze": "Odłóż o {count} min", + "snooze_short": "{count} min", + "open": "Otwórz", + "join": "Dołącz", + "missed_title": "{count, plural, one {# pominięte przypomnienie} few {# pominięte przypomnienia} many {# pominiętych przypomnień} other {# pominiętego przypomnienia}}", + "missed_more": "i jeszcze {count}", + "missed_show": "Pokaż", + "missed_dialog_title": "Pominięte przypomnienia", + "missed_dialog_description": "Pojawiły się, gdy aplikacja była zamknięta, komputer był uśpiony lub powiadomienia były wstrzymane.", + "open_calendar": "Otwórz kalendarz", + "done": "Gotowe" } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 97ed21d66..0b67e7311 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copiar como ficheiro", "copied": "Ficheiro copiado. Pode colá-lo numa pasta.", "copy_failed": "Não foi possível copiar o ficheiro." + }, + "desktopReminders": { + "generic_title": "Lembrete", + "snooze": "Adiar {count} min", + "snooze_short": "{count} min", + "open": "Abrir", + "join": "Entrar", + "missed_title": "{count, plural, one {# lembrete perdido} other {# lembretes perdidos}}", + "missed_more": "e mais {count}", + "missed_show": "Mostrar", + "missed_dialog_title": "Lembretes perdidos", + "missed_dialog_description": "Venceram enquanto a aplicação estava fechada, o computador em suspensão ou as notificações em pausa.", + "open_calendar": "Abrir calendário", + "done": "Concluído" } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 0936d12f4..f03956657 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copiază ca fișier", "copied": "Fișier copiat. Îl poți lipi într-un dosar.", "copy_failed": "Fișierul nu a putut fi copiat." + }, + "desktopReminders": { + "generic_title": "Memento", + "snooze": "Amână {count} min", + "snooze_short": "{count} min", + "open": "Deschide", + "join": "Participă", + "missed_title": "{count, plural, one {# memento ratat} few {# mementouri ratate} other {# de mementouri ratate}}", + "missed_more": "și încă {count}", + "missed_show": "Afișează", + "missed_dialog_title": "Mementouri ratate", + "missed_dialog_description": "Au venit la scadență cât timp aplicația era închisă, computerul în repaus sau notificările în pauză.", + "open_calendar": "Deschide calendarul", + "done": "Gata" } } diff --git a/locales/ru/common.json b/locales/ru/common.json index eb1641895..32c3a46a1 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Копировать как файл", "copied": "Файл скопирован. Его можно вставить в папку.", "copy_failed": "Не удалось скопировать файл." + }, + "desktopReminders": { + "generic_title": "Напоминание", + "snooze": "Отложить на {count} мин", + "snooze_short": "{count} мин", + "open": "Открыть", + "join": "Подключиться", + "missed_title": "{count, plural, one {# пропущенное напоминание} few {# пропущенных напоминания} many {# пропущенных напоминаний} other {# пропущенного напоминания}}", + "missed_more": "и ещё {count}", + "missed_show": "Показать", + "missed_dialog_title": "Пропущенные напоминания", + "missed_dialog_description": "Они сработали, пока приложение было закрыто, компьютер спал или уведомления были приостановлены.", + "open_calendar": "Открыть календарь", + "done": "Готово" } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 4431aa2a7..cb16abd32 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopírovať ako súbor", "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.", "copy_failed": "Súbor sa nepodarilo skopírovať." + }, + "desktopReminders": { + "generic_title": "Pripomienka", + "snooze": "Odložiť o {count} min", + "snooze_short": "{count} min", + "open": "Otvoriť", + "join": "Pripojiť sa", + "missed_title": "{count, plural, one {# zmeškaná pripomienka} few {# zmeškané pripomienky} many {# zmeškanej pripomienky} other {# zmeškaných pripomienok}}", + "missed_more": "a ďalšie ({count})", + "missed_show": "Zobraziť", + "missed_dialog_title": "Zmeškané pripomienky", + "missed_dialog_description": "Nastali, keď bola aplikácia zatvorená, počítač v režime spánku alebo oznámenia pozastavené.", + "open_calendar": "Otvoriť kalendár", + "done": "Hotovo" } } diff --git a/locales/th/common.json b/locales/th/common.json index 47156ac85..594e81712 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6599,5 +6599,19 @@ "copy_as_file": "คัดลอกเป็นไฟล์", "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้", "copy_failed": "คัดลอกไฟล์ไม่ได้" + }, + "desktopReminders": { + "generic_title": "การแจ้งเตือน", + "snooze": "เลื่อน {count} นาที", + "snooze_short": "{count} นาที", + "open": "เปิด", + "join": "เข้าร่วม", + "missed_title": "{count, plural, other {การแจ้งเตือนที่พลาด # รายการ}}", + "missed_more": "และอีก {count} รายการ", + "missed_show": "แสดง", + "missed_dialog_title": "การแจ้งเตือนที่พลาด", + "missed_dialog_description": "ถึงเวลาแจ้งเตือนขณะที่แอปปิดอยู่ คอมพิวเตอร์อยู่ในโหมดพัก หรือหยุดการแจ้งเตือนชั่วคราว", + "open_calendar": "เปิดปฏิทิน", + "done": "เสร็จ" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 391215501..946a1ab7b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Dosya olarak kopyala", "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.", "copy_failed": "Dosya kopyalanamadı." + }, + "desktopReminders": { + "generic_title": "Anımsatıcı", + "snooze": "{count} dk ertele", + "snooze_short": "{count} dk", + "open": "Aç", + "join": "Katıl", + "missed_title": "{count, plural, one {Kaçırılan # anımsatıcı} other {Kaçırılan # anımsatıcı}}", + "missed_more": "ve {count} tane daha", + "missed_show": "Göster", + "missed_dialog_title": "Kaçırılan anımsatıcılar", + "missed_dialog_description": "Uygulama kapalıyken, bilgisayar uykudayken veya bildirimler duraklatılmışken zamanı geldi.", + "open_calendar": "Takvimi aç", + "done": "Bitti" } } diff --git a/locales/uk/common.json b/locales/uk/common.json index ebed90c4e..45a4898b1 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Копіювати як файл", "copied": "Файл скопійовано. Його можна вставити в папку.", "copy_failed": "Не вдалося скопіювати файл." + }, + "desktopReminders": { + "generic_title": "Нагадування", + "snooze": "Відкласти на {count} хв", + "snooze_short": "{count} хв", + "open": "Відкрити", + "join": "Приєднатися", + "missed_title": "{count, plural, one {# пропущене нагадування} few {# пропущені нагадування} many {# пропущених нагадувань} other {# пропущеного нагадування}}", + "missed_more": "і ще {count}", + "missed_show": "Показати", + "missed_dialog_title": "Пропущені нагадування", + "missed_dialog_description": "Вони спрацювали, коли застосунок було закрито, комп'ютер спав або сповіщення були призупинені.", + "open_calendar": "Відкрити календар", + "done": "Готово" } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 86de8e353..51222b054 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6603,5 +6603,19 @@ "copy_as_file": "Sao chép dưới dạng tệp", "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.", "copy_failed": "Không thể sao chép tệp." + }, + "desktopReminders": { + "generic_title": "Lời nhắc", + "snooze": "Báo lại sau {count} phút", + "snooze_short": "{count} phút", + "open": "Mở", + "join": "Tham gia", + "missed_title": "{count, plural, other {# lời nhắc đã bỏ lỡ}}", + "missed_more": "và {count} lời nhắc khác", + "missed_show": "Hiển thị", + "missed_dialog_title": "Lời nhắc đã bỏ lỡ", + "missed_dialog_description": "Những lời nhắc này đến hạn khi ứng dụng đóng, máy tính ngủ hoặc thông báo bị tạm dừng.", + "open_calendar": "Mở lịch", + "done": "Xong" } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f3fb5a71a..8004dc647 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "複製為檔案", "copied": "檔案已複製。您可以將其貼到資料夾中。", "copy_failed": "無法複製檔案。" + }, + "desktopReminders": { + "generic_title": "提醒", + "snooze": "{count} 分鐘後再提醒", + "snooze_short": "{count} 分鐘", + "open": "開啟", + "join": "加入", + "missed_title": "{count, plural, other {# 則錯過的提醒}}", + "missed_more": "還有 {count} 則", + "missed_show": "顯示", + "missed_dialog_title": "錯過的提醒", + "missed_dialog_description": "這些提醒在應用程式關閉、電腦睡眠或通知暫停時到期。", + "open_calendar": "開啟日曆", + "done": "完成" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 3058a98c8..58a804589 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "复制为文件", "copied": "文件已复制。你可以将其粘贴到文件夹中。", "copy_failed": "无法复制文件。" + }, + "desktopReminders": { + "generic_title": "提醒", + "snooze": "{count} 分钟后再提醒", + "snooze_short": "{count} 分钟", + "open": "打开", + "join": "加入", + "missed_title": "{count, plural, other {# 条错过的提醒}}", + "missed_more": "还有 {count} 条", + "missed_show": "显示", + "missed_dialog_title": "错过的提醒", + "missed_dialog_description": "这些提醒在应用关闭、电脑睡眠或通知暂停时到期。", + "open_calendar": "打开日历", + "done": "完成" } } diff --git a/public/sw.js b/public/sw.js index 22e4174ba..581108f36 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1096,6 +1096,7 @@ function getReusableClientScore(state) { function buildClickUrl(data, reply) { if (!data) return `${BASE_PATH}/`; + if (data.kind === "calendar-reminder") return `${BASE_PATH}/calendar`; const params = new URLSearchParams(); if (Number.isInteger(data.slot)) params.set("slot", String(data.slot)); if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId); diff --git a/stores/calendar-notification-store.ts b/stores/calendar-notification-store.ts index da0a266ea..8e7d41983 100644 --- a/stores/calendar-notification-store.ts +++ b/stores/calendar-notification-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { clearClaims } from '@/lib/calendar-reminders/claims'; const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000; @@ -37,6 +38,7 @@ export const useCalendarNotificationStore = create()( clearAll: () => { set({ acknowledgedAlerts: {} }); + clearClaims(); }, }), { From 580a7a645d7967eb07febb6444c6eccc3dbd4de6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:50:21 +0100 Subject: [PATCH 13/45] test: check that signing out clears what the desktop app shows outside its windows --- lib/desktop/__tests__/os-signout.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 lib/desktop/__tests__/os-signout.test.ts diff --git a/lib/desktop/__tests__/os-signout.test.ts b/lib/desktop/__tests__/os-signout.test.ts new file mode 100644 index 000000000..d640b50b7 --- /dev/null +++ b/lib/desktop/__tests__/os-signout.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** A sign-out leaves nothing of the account on the OS's surfaces. */ + +vi.setConfig({ testTimeout: 60_000 }); + +describe('sign-out', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + it('clears what the desktop app shows outside its windows', async () => { + const clearSurfaces = vi.fn(); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never; + const { purgeSignedOutData } = await import('@/lib/sign-out-cleanup'); + purgeSignedOutData(); + expect(clearSurfaces).toHaveBeenCalled(); + }); + + it('in a browser there is nothing to clear', async () => { + const { clearDesktopSurfaces } = await import('../bridge'); + expect(() => clearDesktopSurfaces()).not.toThrow(); + }); +}); From e20aded171fadd55d0ae66a368d45191dd025c90 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:53:40 +0100 Subject: [PATCH 14/45] fix: keep restored desktop composers off sent mail and on their own From, check links from the native menu, and fetch no dictionary without asking A restored composer opened whatever the id named. Sending keeps the id, so its autosave would have destroyed the Sent copy, Send mailed it twice and Discard deleted it. It now needs the login in its address and a real draft, keeps its From with several accounts, and stays on the list if the server doesn't answer. A message from a bulwark:// link no longer marks itself read or sends a receipt. Native menus speak all 30 languages. Spellcheck dictionaries come from Google only after a yes, never in no-third-party mode. --- .../desktop/__tests__/desktop-host.test.tsx | 168 +++++++++++++++++- components/desktop/desktop-host.tsx | 102 +++++++++-- .../email/__tests__/viewer-mark-read.test.tsx | 8 + .../__tests__/viewer-send-failures.test.tsx | 8 + components/email/email-composer.tsx | 14 +- components/email/email-viewer.tsx | 10 +- .../__tests__/popout-compose-app.test.tsx | 144 +++++++++++++-- .../__tests__/popout-message-app.test.tsx | 15 ++ components/popout/popout-compose-app.tsx | 118 ++++++++++-- components/popout/popout-message-app.tsx | 9 +- components/pro/pro-email-tab-body.tsx | 5 +- components/ui/context-menu.tsx | 16 +- lib/__tests__/outgoing-hosts-mode.test.ts | 4 + lib/admin/outgoing-hosts-registry.ts | 10 ++ lib/compose-flush-registry.ts | 16 ++ lib/desktop/__tests__/native-ui.test.tsx | 39 +++- lib/desktop/dictionary-download.ts | 14 ++ lib/desktop/languages.ts | 128 +++++++++++++ lib/desktop/native-menu.ts | 60 ++++++- lib/desktop/open-link.ts | 22 +++ lib/desktop/preferences.ts | 5 +- lib/desktop/types.ts | 15 +- lib/draft-composer-data.ts | 17 +- lib/outgoing-hosts.ts | 1 + locales/ar/common.json | 79 ++++++++ locales/ca/common.json | 79 ++++++++ locales/cs/common.json | 79 ++++++++ locales/da/common.json | 79 ++++++++ locales/de/common.json | 79 ++++++++ locales/en/common.json | 79 ++++++++ locales/es/common.json | 79 ++++++++ locales/et/common.json | 79 ++++++++ locales/fa/common.json | 79 ++++++++ locales/fr/common.json | 79 ++++++++ locales/he/common.json | 79 ++++++++ locales/hu/common.json | 79 ++++++++ locales/it/common.json | 79 ++++++++ locales/ja/common.json | 79 ++++++++ locales/ko/common.json | 79 ++++++++ locales/lv/common.json | 79 ++++++++ locales/mn/common.json | 79 ++++++++ locales/nb/common.json | 79 ++++++++ locales/nl/common.json | 79 ++++++++ locales/pl/common.json | 79 ++++++++ locales/pt/common.json | 79 ++++++++ locales/ro/common.json | 79 ++++++++ locales/ru/common.json | 79 ++++++++ locales/sk/common.json | 79 ++++++++ locales/th/common.json | 79 ++++++++ locales/tr/common.json | 79 ++++++++ locales/uk/common.json | 79 ++++++++ locales/vi/common.json | 79 ++++++++ locales/zh-TW/common.json | 79 ++++++++ locales/zh/common.json | 79 ++++++++ 54 files changed, 3246 insertions(+), 72 deletions(-) create mode 100644 lib/desktop/dictionary-download.ts create mode 100644 lib/desktop/languages.ts create mode 100644 lib/desktop/open-link.ts diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx index b44514e6f..9f6da218b 100644 --- a/components/desktop/__tests__/desktop-host.test.tsx +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -2,7 +2,10 @@ import { act, cleanup, render, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BulwarkDesktopBridge } from '@/lib/desktop/types'; import { registerDesktopSurface, resetDesktopCommands } from '@/lib/desktop/commands'; -import { resetDesktopPrefsCache, setDesktopPref } from '@/lib/desktop/preferences'; +import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/desktop/preferences'; +import { resetDesktopLanguages } from '@/lib/desktop/languages'; +import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; +import { useToastStore } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useLocaleStore } from '@/stores/locale-store'; import { useOfflineStore } from '@/lib/offline/state'; @@ -13,36 +16,65 @@ vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) })); const canPrint = vi.fn(() => true); const canDownload = vi.fn(() => true); vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() })); +vi.mock('@/hooks/use-device-policy', () => ({ useStrictestDevices: () => strictest.current })); +const strictest = vi.hoisted(() => ({ current: {} as object })); + +const resolveExternalLink = vi.fn(async (ctx: { href: string }) => ctx.href as string | null); +vi.mock('@/lib/plugin-hook-result', () => ({ resolveExternalLink: (ctx: { href: string }) => resolveExternalLink(ctx) })); +const requestInternalMailto = vi.fn((_raw: string) => true); +vi.mock('@/lib/protocol-handlers/mailto', () => ({ requestInternalMailto: (raw: string) => requestInternalMailto(raw) })); +const allowed = vi.hoisted(() => ({ dictionary: true })); +vi.mock('@/lib/desktop/dictionary-download', () => ({ dictionaryDownloadAllowed: () => allowed.dictionary })); +const signOut = vi.hoisted(() => ({ full: [] as (() => void)[], before: [] as (() => Promise)[] })); +vi.mock('@/lib/sign-out-cleanup', async (original) => ({ + ...(await original()), + onFullSignOut: (task: () => void) => signOut.full.push(task), + onBeforeSignOut: (task: () => Promise) => signOut.before.push(task), +})); const { DesktopHost } = await import('../desktop-host'); function fakeBridge() { let command: ((id: string) => void) | null = null; let flush: (() => Promise) | null = null; + let missing: ((language: string) => void) | null = null; const parts = { setState: vi.fn(), setLanguages: vi.fn(), sessionReady: vi.fn(), setRestoreEnabled: vi.fn(), setTheme: vi.fn(), + openExternal: vi.fn(), + allowDownload: vi.fn(), + clearFiles: vi.fn(), }; const bridge = { version: 1, platform: 'win32', menu: { setState: parts.setState, onCommand: (listener: (id: string) => void) => { command = listener; return () => {}; } }, - shell: { openExternal: () => {}, setLanguages: parts.setLanguages }, + shell: { openExternal: parts.openExternal, setLanguages: parts.setLanguages }, + spellcheck: { missing: async () => [], onMissing: (listener: (language: string) => void) => { missing = listener; return () => {}; }, allowDownload: parts.allowDownload }, + clipboard: { copyFile: async () => true, clearFiles: parts.clearFiles }, windows: { sessionReady: parts.sessionReady, setRestoreEnabled: parts.setRestoreEnabled }, theme: { set: parts.setTheme, system: async () => ({ dark: false, highContrast: false }) }, lifecycle: { onFlushRequest: (handler: () => Promise) => { flush = handler; return () => {}; } }, }; window.bulwarkDesktop = bridge as unknown as BulwarkDesktopBridge; - return { ...parts, run: (id: string) => act(() => command!(id)), flush: () => flush!() }; + return { ...parts, run: (id: string) => act(() => command!(id)), flush: () => flush!(), missing: (language: string) => act(() => missing!(language)) }; } const original = useAuthStore.getState(); beforeEach(() => { push.mockClear(); + resolveExternalLink.mockClear(); + resolveExternalLink.mockImplementation(async (ctx) => ctx.href); + requestInternalMailto.mockClear(); + requestInternalMailto.mockReturnValue(true); + allowed.dictionary = true; + signOut.full.length = 0; + signOut.before.length = 0; + resetDesktopLanguages(); canPrint.mockReturnValue(true); canDownload.mockReturnValue(true); localStorage.clear(); @@ -141,6 +173,136 @@ describe('DesktopHost', () => { expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' }); }); + describe('Open Link in a native menu', () => { + const openLink = (url: string) => `link.open:${JSON.stringify({ url, text: 'click' })}`; + + it('goes past the same check as a click on it', async () => { + const shell = fakeBridge(); + render(); + shell.run(openLink('https://example.org/a')); + await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://example.org/a')); + expect(resolveExternalLink).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://example.org/a', text: 'click' })); + }); + + it('a plugin can stop it', async () => { + resolveExternalLink.mockResolvedValue(null); + const shell = fakeBridge(); + render(); + shell.run(openLink('https://example.org/a')); + await waitFor(() => expect(resolveExternalLink).toHaveBeenCalled()); + await act(async () => {}); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('a plugin can send it elsewhere', async () => { + resolveExternalLink.mockResolvedValue('https://safe.example.org/'); + const shell = fakeBridge(); + render(); + shell.run(openLink('https://example.org/a')); + await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://safe.example.org/')); + }); + + it('a mail address goes to the app\'s composer', async () => { + const shell = fakeBridge(); + render(); + shell.run(openLink('mailto:a@example.org')); + await waitFor(() => expect(requestInternalMailto).toHaveBeenCalledWith('mailto:a@example.org')); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('tells the shell it has this path, so an older page keeps the direct way', async () => { + const shell = fakeBridge(); + render(); + await waitFor(() => expect(shell.setState).toHaveBeenCalled()); + expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'link.check': true }); + }); + }); + + it('hands the shell its menus in the interface language', async () => { + const shell = fakeBridge(); + act(() => useLocaleStore.setState({ locale: 'fr' })); + render(); + await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled()); + const { labels } = shell.setLanguages.mock.lastCall![0]; + // The test's next-intl gives the key back. + expect(labels.menuFile).toBe('desktopUi.menu.file'); + expect(labels.menuReply).toBe('context_menu.reply'); + expect(labels.unsentMany).toBe('desktopUi.closing.unsent_many'); + }); + + describe('a language with no checker in the OS', () => { + afterEach(() => resetComposeFlushRegistry()); + const toasts = () => useToastStore.getState().toasts; + + it('the composer asks once, and only a yes opens the downloads', async () => { + useToastStore.setState({ toasts: [] }); + const shell = fakeBridge(); + render(); + await act(async () => {}); + shell.missing('nl-NL'); + // No composer open: nobody is writing yet. + expect(toasts()).toHaveLength(0); + const stop = registerComposeFlush(async () => {}); + await waitFor(() => expect(toasts()).toHaveLength(1)); + expect(shell.allowDownload).not.toHaveBeenCalled(); + act(() => toasts()[0].action!.onClick()); + expect(shell.allowDownload).toHaveBeenCalledTimes(1); + expect(readDesktopPrefs().dictionaryDownload).toBe('yes'); + stop(); + }); + + it('never a second time', async () => { + useToastStore.setState({ toasts: [] }); + setDesktopPref('dictionaryDownload', 'no'); + const shell = fakeBridge(); + render(); + await act(async () => {}); + const stop = registerComposeFlush(async () => {}); + shell.missing('nl-NL'); + await act(async () => {}); + expect(toasts()).toHaveLength(0); + stop(); + }); + + it('never in no-third-party mode, even after an old yes', async () => { + useToastStore.setState({ toasts: [] }); + allowed.dictionary = false; + setDesktopPref('dictionaryDownload', 'yes'); + const first = fakeBridge(); + const view = render(); + await act(async () => {}); + expect(first.allowDownload).not.toHaveBeenCalled(); + view.unmount(); + setDesktopPref('dictionaryDownload', 'ask'); + const shell = fakeBridge(); + render(); + await act(async () => {}); + const stop = registerComposeFlush(async () => {}); + shell.missing('nl-NL'); + await act(async () => {}); + expect(toasts()).toHaveLength(0); + stop(); + }); + + it('a yes from before opens them at the next start', async () => { + setDesktopPref('dictionaryDownload', 'yes'); + const shell = fakeBridge(); + render(); + await act(async () => {}); + expect(shell.allowDownload).toHaveBeenCalledTimes(1); + }); + }); + + it('signing out takes the files copied to the clipboard with it', async () => { + const shell = fakeBridge(); + render(); + await act(async () => {}); + signOut.full[0](); + expect(shell.clearFiles).toHaveBeenCalledTimes(1); + await signOut.before[0](); + expect(shell.clearFiles).toHaveBeenCalledTimes(2); + }); + it('a quit is told how much mail is waiting', async () => { const shell = fakeBridge(); render(); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 83c6722e3..f8295a3ad 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -1,22 +1,30 @@ "use client"; import { useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { useStrictestDevices } from "@/hooks/use-device-policy"; import { useRouter } from "@/i18n/navigation"; import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands"; +import { onComposerOpened, composerIsOpen } from "@/lib/compose-flush-registry"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import { runDesktopCommand, setDesktopBaseState } from "@/lib/desktop/commands"; -import { useDesktopPrefs } from "@/lib/desktop/preferences"; +import { dictionaryDownloadAllowed } from "@/lib/desktop/dictionary-download"; +import { noteComposerLanguage, setDesktopInterfaceLanguage, shellLabels } from "@/lib/desktop/languages"; +import { openLinkFromNativeMenu } from "@/lib/desktop/open-link"; +import { readDesktopPrefs, setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences"; import { applySystemAccent } from "@/lib/desktop/system-accent"; import type { DesktopSystemTheme } from "@/lib/desktop/types"; import { canDownloadAnywhere, canPrintAnywhere } from "@/lib/device-policy"; import { useOfflineStore } from "@/lib/offline/state"; import { openNewComposerWindow } from "@/lib/popout/open"; import { isPopoutWindow } from "@/lib/popout/window-role"; +import { onBeforeSignOut, onFullSignOut } from "@/lib/sign-out-cleanup"; import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; import { useLocaleStore } from "@/stores/locale-store"; -import { usePolicyStore } from "@/stores/policy-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useThemeStore } from "@/stores/theme-store"; +import { toast } from "@/stores/toast-store"; /** * The webmail's side of what every desktop window needs: runs menu commands nothing @@ -28,13 +36,15 @@ export function DesktopHost() { const router = useRouter(); const routerRef = useRef(router); routerRef.current = router; + const t = useTranslations(); + const tRef = useRef(t); + tRef.current = t; const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts); const writingLanguage = useSettingsStore((s) => s.writingLanguage); const uiLocale = useLocaleStore((s) => s.locale); - const policyLoaded = usePolicyStore((s) => s.loaded); - const accounts = useAuthStore((s) => s.getAllConnectedClients().size); + const strictest = useStrictestDevices(); const { restoreWindows } = useDesktopPrefs(); const theme = useThemeStore((s) => s.theme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); @@ -68,6 +78,16 @@ export function DesktopHost() { routerRef.current.push(`/mail/folder/${encodeURIComponent(id.slice("link.folder:".length))}`); return; } + // Open Link in a native menu. + if (id.startsWith("link.open:")) { + try { + const { url, text } = JSON.parse(id.slice("link.open:".length)) as { url: string; text: string }; + void openLinkFromNativeMenu(url, text, useEmailStore.getState().selectedEmail?.id); + } catch { + // Only the shell sends this, and it sends JSON. + } + return; + } if (!runDesktopCommand(id)) fallback[id]?.(); }); }, []); @@ -79,6 +99,7 @@ export function DesktopHost() { const signedIn = isAuthenticated; const popout = isPopoutWindow(); setDesktopBaseState({ + "link.check": true, "shortcuts.singleKeys": singleKeys, "policy.download": canDownloadAnywhere(), "file.print": canPrint, @@ -92,24 +113,79 @@ export function DesktopHost() { "go.files": signedIn, "help.shortcuts": true, }); - }, [isAuthenticated, singleKeys, policyLoaded, accounts]); + }, [isAuthenticated, singleKeys, strictest]); - // Menus and spellcheck follow the interface, and the composer's language too. + // Menus and spellcheck follow the interface. useEffect(() => { - const shell = getDesktopBridge()?.shell; - if (!shell?.setLanguages) return; const ui = uiLocale || document.documentElement.lang; - if (!ui) return; - const composer = writingLanguage && writingLanguage !== "auto" ? [writingLanguage] : []; - shell.setLanguages({ ui, composer }); - }, [uiLocale, writingLanguage]); + if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as (key: string) => string)); + }, [uiLocale]); + + // What the user writes in, whichever composer asks for it too. + useEffect(() => { + noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null); + }, [writingLanguage]); + + // A language with no checker in the OS needs a dictionary from Google: the composer + // asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to + // fetch from at all. + useEffect(() => { + const spellcheck = getDesktopBridge()?.spellcheck; + if (!spellcheck) return; + if (readDesktopPrefs().dictionaryDownload === "yes" && dictionaryDownloadAllowed()) spellcheck.allowDownload(); + const waiting = new Set(); + const offer = () => { + const [language] = waiting; + if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return; + if (readDesktopPrefs().dictionaryDownload !== "ask") return; + // Asked once, whatever comes of it. + setDesktopPref("dictionaryDownload", "no"); + const translate = tRef.current; + let name = language; + try { + name = new Intl.DisplayNames([document.documentElement.lang || "en"], { type: "language" }).of(language) ?? language; + } catch { + // The tag itself will do. + } + toast.info(translate("desktopUi.spellcheck.offer", { language: name }), { + duration: 0, + action: { + label: translate("desktopUi.spellcheck.download"), + onClick: () => { + setDesktopPref("dictionaryDownload", "yes"); + if (dictionaryDownloadAllowed()) spellcheck.allowDownload(); + }, + }, + }); + }; + const heard = (language: string) => { + waiting.add(language); + offer(); + }; + void spellcheck.missing().then((list) => list.forEach(heard)); + const stopMissing = spellcheck.onMissing(heard); + const stopComposer = onComposerOpened(offer); + return () => { + stopMissing(); + stopComposer(); + }; + }, []); + + // Signed out: nothing of that account stays on the file clipboard. + useEffect(() => { + const clipboard = getDesktopBridge()?.clipboard; + if (!clipboard?.clearFiles) return; + const clear = () => clipboard.clearFiles?.(); + onFullSignOut(clear); + onBeforeSignOut(async () => clear()); + }, []); // The windows' behaviour lives here, the shell only needs its copy. useEffect(() => { getDesktopBridge()?.windows?.setRestoreEnabled?.(restoreWindows); }, [restoreWindows]); - // Signed in: this window has a session to lend, so pop-outs from last time can open. + // Signed in: this window has a session to lend, for pop-outs from last time and links. useEffect(() => { if (!isAuthenticated || isPopoutWindow()) return; getDesktopBridge()?.windows?.sessionReady?.(); diff --git a/components/email/__tests__/viewer-mark-read.test.tsx b/components/email/__tests__/viewer-mark-read.test.tsx index 86e0f4dd8..569b0c285 100644 --- a/components/email/__tests__/viewer-mark-read.test.tsx +++ b/components/email/__tests__/viewer-mark-read.test.tsx @@ -75,6 +75,14 @@ describe('EmailViewer mark as read', () => { await waitFor(() => expect(onMarkAsRead).toHaveBeenCalledWith('e1', true, { automatic: true })); }); + it('a message opened from a link stays unread until the user says', async () => { + useSettingsStore.setState({ markAsReadDelay: 0 }); + const onMarkAsRead = vi.fn(); + render(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(onMarkAsRead).not.toHaveBeenCalled(); + }); + it("passes the toolbar button on as the user's own change", () => { useSettingsStore.setState({ markAsReadDelay: -1 }); const onMarkAsRead = vi.fn(); diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index 759465667..fd95d5e73 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -271,6 +271,14 @@ describe('viewer send failures', () => { useAuthStore.setState({ client: null, identities: [] } as never); }); + it('a message opened from a link sends none by itself', async () => { + render(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(sendReadReceipt).not.toHaveBeenCalled(); + }); + it('tries once per opening, not once per refresh, when the quota is full on Stalwart 1.0', async () => { const props = { mailboxes, selectedMailbox: 'inbox', currentMailboxRole: 'inbox' }; const { rerender } = render(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 67be255df..b3037cede 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -170,6 +170,7 @@ import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { noteComposerLanguage } from "@/lib/desktop/languages"; import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; @@ -385,7 +386,7 @@ interface EmailComposerProps { /** The state as the window unloads, for a reload to start from. */ onUnloadState?: (data: ComposerDraftData) => void; /** A save reached the server, for other windows showing the Drafts folder. */ - onDraftSaved?: () => void; + onDraftSaved?: (draftId: string) => void; /** * Hands a draft to another window to save as this one closes: a request * started now wouldn't survive. False when that can't be done safely. @@ -1948,7 +1949,7 @@ export function EmailComposer({ // A composer that comes back with the app: a draft it opened on counts from the start. // Gone with the composer: sent, discarded or closed. useEffect(() => { - if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current }); + if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountIdRef.current }); return () => noteOpenDraft(effectiveSessionKey, null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveSessionKey]); @@ -3028,7 +3029,7 @@ export function EmailComposer({ draftAccountIdRef.current = draftAccountId; draftLocalAccountIdRef.current = savingLogin; // Before a save's promise settles: quitting waits for it, then keeps this list. - noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin, ownerAccountId: draftAccountId }); + noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountId }); setDraftId(savedDraftId); setDraftAccountId(draftAccountId); setDraftLocalAccountId(savingLogin); @@ -3109,7 +3110,7 @@ export function EmailComposer({ // Only the save the user asked for is read out: autosave would say it // every minute, and the save before sending is followed by "Sent". if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved')); - if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(); + if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(id); return id; })(); inflightSaveRef.current = promise; @@ -3372,6 +3373,11 @@ export function EmailComposer({ const uiLocale = useLocale(); const writingLanguage = useSettingsStore((s) => s.writingLanguage); const textLang = writingLanguage && writingLanguage !== 'auto' ? writingLanguage : uiLocale; + // The desktop app's spellcheck follows what's being written. + useEffect(() => { + noteComposerLanguage(effectiveSessionKey, textLang); + return () => noteComposerLanguage(effectiveSessionKey, null); + }, [effectiveSessionKey, textLang]); const proofreading = useProofreading({ composerId: pluginComposerId, sessionKey: effectiveSessionKey, diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cd85eacd2..d4fd058dd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -290,6 +290,11 @@ interface EmailViewerProps { onToggleFullscreen?: () => void; /** Opens this message in its own window; left out where that isn't on offer. */ onOpenInWindow?: () => void; + /** + * Opened by something outside the app (a bulwark:// link), not by the user in it. + * It shows the message and does nothing on its own: no mark as read, no read receipt. + */ + noAutomaticActions?: boolean; isFullscreen?: boolean; currentUserEmail?: string; currentUserName?: string; @@ -1324,6 +1329,7 @@ export function EmailViewer({ onCompose, onToggleFullscreen, onOpenInWindow, + noAutomaticActions = false, isFullscreen = false, currentUserEmail, currentUserName, @@ -2200,7 +2206,7 @@ export function EmailViewer({ useEffect(() => { // Mark as read when email is viewed, respecting the delay setting - if (!email || !onMarkAsRead) { + if (!email || !onMarkAsRead || noAutomaticActions) { return; } @@ -3948,7 +3954,7 @@ ${printWatermark ? printWatermark.html : ''} if (autoMdnRef.current !== email?.id) autoMdnRef.current = null; }, [email?.id]); useEffect(() => { - if (readReceiptResponse !== 'always') return; + if (readReceiptResponse !== 'always' || noAutomaticActions) return; if (!shouldOfferReadReceipt || !email) return; if (autoMdnRef.current === email.id) return; autoMdnRef.current = email.id; diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 2a02faf0f..7fb96e09b 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -1,10 +1,12 @@ -import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { createComposeHandoff, rememberComposePayload } from '@/lib/popout/compose-handoff'; import { useAuthStore } from '@/stores/auth-store'; +import { useIdentityStore } from '@/stores/identity-store'; import { usePluginStore } from '@/stores/plugin-store'; +import { emailHooks } from '@/lib/plugin-hooks'; // The composer's window: when it tells the opening window to let go, and what // happens on a reload. The composer itself is stubbed; its own tests cover it. @@ -25,9 +27,29 @@ vi.mock('@/components/error', () => ({ ErrorBoundary: ({ children }: { children: vi.mock('../popout-shell', () => ({ PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}, PopoutSkeleton: () =>
, + PopoutNotice: ({ title, children }: { title: string; children: React.ReactNode }) =>

{title}

{children}
, })); vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() })); +const identityMocks = vi.hoisted(() => ({ + multi: { enabled: false, allIdentities: [] as unknown[], groups: [] as unknown[] }, + bump: undefined as undefined | (() => void), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', async () => { + const { useReducer } = await import('react'); + return { + useProMultiAccountIdentities: () => { + const [, bump] = useReducer((n: number) => n + 1, 0); + identityMocks.bump = bump; + return identityMocks.multi; + }, + stripCrossAccountIdentityPrefix: (id: string) => { + const at = id.indexOf('::'); + return at >= 0 ? { localAccountId: id.slice(0, at), rawId: id.slice(at + 2) } : { localAccountId: null, rawId: id }; + }, + }; +}); + const { PopoutComposeApp } = await import('../popout-compose-app'); const original = useAuthStore.getState(); @@ -69,25 +91,40 @@ function openAt(handoffId: string) { describe('a composer coming back on its draft', () => { const closeSelf = vi.fn(); + const setRestore = vi.fn(); + const LOGIN = 'alice@example.org@mail.example.org'; + const MAILBOXES = [ + { id: 'mDrafts', role: 'drafts' }, + { id: 'mSent', role: 'sent' }, + { id: 'mTrash', role: 'trash' }, + { id: 'mInbox', role: 'inbox' }, + ]; beforeEach(() => { closeSelf.mockClear(); - window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never; + setRestore.mockClear(); + identityMocks.multi = { enabled: false, allIdentities: [], groups: [] }; + useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore } } as never; }); afterEach(() => { window.bulwarkDesktop = undefined; + vi.useRealTimers(); }); const draft = { - id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ email: 'me@example.org' }], + id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }], }; - const openDraft = (getEmail: ReturnType, search = 'draft=d1&account=alice&owner=group') => { - useAuthStore.setState({ client: { getEmail } as never, getClientForAccount: () => undefined } as never); + const openDraft = (getEmail: ReturnType, search = `draft=d1&account=${encodeURIComponent(LOGIN)}&owner=group`) => { + const client = { getEmail, getMailboxes: vi.fn(async () => MAILBOXES) }; + useAuthStore.setState({ client: { other: true } as never, getClientForAccount: (id: string) => (id === LOGIN ? client : undefined) } as never); window.history.replaceState(null, '', `/en/popout/compose?${search}`); render(); + return client; }; it('reads the draft from where it lives, and opens the composer on it, id and all', async () => { @@ -96,19 +133,106 @@ describe('a composer coming back on its draft', () => { await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written')); expect(getEmail).toHaveBeenCalledWith('d1', 'group'); // Saving goes over this draft, not next to it, and its files stay. - expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: 'alice', to: 'a@example.org' }); + expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: LOGIN, to: 'a@example.org' }); expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1); }); - it('a draft that has gone since (sent elsewhere) closes the window', async () => { + // Sending keeps the id. A composer on it would destroy the Sent copy at the next + // autosave, Send would mail it twice, and Discard would delete it. + it.each([ + ['a message that was sent', { keywords: { $seen: true }, mailboxIds: { mSent: true } }], + ['a sent message still marked as a draft', { keywords: { $draft: true }, mailboxIds: { mSent: true } }], + ['a draft in the Trash', { keywords: { $draft: true }, mailboxIds: { mTrash: true } }], + ['mail in the Inbox that is no draft at all', { keywords: {}, mailboxIds: { mInbox: true } }], + ])('%s is not opened', async (_name, shape) => { + openDraft(vi.fn(async () => ({ ...draft, ...shape }))); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId('composer')).toBeNull(); + }); + + it('a send brought back has no $draft, and in Drafts it opens', async () => { + openDraft(vi.fn(async () => ({ ...draft, keywords: {}, mailboxIds: { mDrafts: true } }))); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + }); + + it('an address with no login opens nothing, not the active account\'s mail under the same id', async () => { + const getEmail = vi.fn(async () => draft); + useAuthStore.setState({ client: { getEmail, getMailboxes: async () => MAILBOXES } as never, getClientForAccount: () => undefined } as never); + window.history.replaceState(null, '', '/en/popout/compose?draft=d1'); + render(); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(getEmail).not.toHaveBeenCalled(); + }); + + it('a draft gone since closes the window', async () => { openDraft(vi.fn(async () => null)); await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); expect(screen.queryByTestId('composer')).toBeNull(); }); - it('a server that doesn\'t answer closes it too: the draft is safe on the server', async () => { - openDraft(vi.fn(async () => { throw new Error('offline'); })); - await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + it('a server not answering keeps the window and the entry, and Try again works', async () => { + let up = false; + const getEmail = vi.fn(async () => { + if (!up) throw new Error('offline'); + return draft; + }); + openDraft(getEmail); + await screen.findByText('unavailable_title'); + expect(closeSelf).not.toHaveBeenCalled(); + // On the list for next time. + expect(setRestore.mock.lastCall![0]).toEqual([expect.stringContaining('draft=d1')]); + up = true; + fireEvent.click(screen.getByText('try_again')); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + }); + + it('plugins get their say on the draft, as when it opens from the list', async () => { + const hook = emailHooks.onBeforeEditDraft.register('test', ((value: unknown) => ({ ...(value as object), subject: 'Changed by a plugin' })) as never); + try { + openDraft(vi.fn(async () => draft)); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Changed by a plugin')); + } finally { + hook.dispose(); + } + }); + + describe('with several accounts', () => { + const OTHER = { id: 'other@x@h::id-9', email: 'other@example.org', name: 'Other', localAccountId: LOGIN }; + + it('waits for the identity that wrote it, however late it loads, and starts on that', async () => { + identityMocks.multi = { enabled: true, allIdentities: [], groups: [] }; + const fromOther = { ...draft, from: [{ name: 'Other', email: 'other@example.org' }] }; + openDraft(vi.fn(async () => fromOther)); + await settle(); + // Not on the default in the meantime: it would save that. + expect(screen.queryByTestId('composer')).toBeNull(); + act(() => { + identityMocks.multi = { enabled: true, allIdentities: [OTHER], groups: [] }; + identityMocks.bump?.(); + }); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + expect(composer?.initialData).toMatchObject({ selectedIdentityId: OTHER.id }); + expect((composer?.initialData as unknown as { fromOverrideEnabled?: boolean }).fromOverrideEnabled).toBeUndefined(); + }); + + it('an address no identity has any more stays as the From, not the default', async () => { + identityMocks.multi = { enabled: true, allIdentities: [], groups: [] }; + vi.useFakeTimers({ shouldAdvanceTime: true }); + openDraft(vi.fn(async () => ({ ...draft, from: [{ name: 'Gone', email: 'gone@example.org' }] }))); + await act(async () => { + await vi.advanceTimersByTimeAsync(4100); + }); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + expect(composer?.initialData).toMatchObject({ selectedIdentityId: null, fromOverrideEnabled: true, fromOverrideEmail: 'gone@example.org', fromOverrideName: 'Gone' }); + }); + }); + + it('a save moves the address on, or a reload would look for a version that is gone', async () => { + openDraft(vi.fn(async () => draft)); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + act(() => (composer as unknown as { ownWindow: { onDraftSaved: (id: string) => void } }).ownWindow.onDraftSaved('d2')); + expect(new URLSearchParams(window.location.search).get('draft')).toBe('d2'); + expect(new URLSearchParams(window.location.search).get('account')).toBe(LOGIN); }); }); diff --git a/components/popout/__tests__/popout-message-app.test.tsx b/components/popout/__tests__/popout-message-app.test.tsx index 8d877a955..82e6a7d69 100644 --- a/components/popout/__tests__/popout-message-app.test.tsx +++ b/components/popout/__tests__/popout-message-app.test.tsx @@ -78,6 +78,21 @@ afterEach(() => { window.history.replaceState(null, '', '/'); }); +describe('a message opened from a bulwark:// link', () => { + it('asks for nothing automatic: such a link may show a message, not act on it', async () => { + window.history.replaceState(null, '', '/en/popout/message/m%2F1?account=login-b&via=link'); + render(); + await settle(); + expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBe(true); + }); + + it('an ordinary pop-out acts as ever', async () => { + render(); + await settle(); + expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBeFalsy(); + }); +}); + describe('PopoutMessageApp', () => { it('opens the message of the address, through its login and account', async () => { render(); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 7aaa4f709..11e02e952 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -18,9 +18,15 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { closePopoutWindow } from "@/lib/popout/window-role"; -import { composerDataFromDraft } from "@/lib/draft-composer-data"; +import { Button } from "@/components/ui/button"; +import { composerDataFromDraft, isRestorableDraft } from "@/lib/draft-composer-data"; +import { noteOpenDraft } from "@/lib/desktop/window-restore"; +import { findDraftIdentityIdForLogin } from "@/lib/draft-source"; +import { emailHooks } from "@/lib/plugin-hooks"; +import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useIdentityStore } from "@/stores/identity-store"; -import { PopoutShell, PopoutSkeleton } from "./popout-shell"; +import type { Email } from "@/lib/jmap/types"; +import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ const WARNING_READ_MS = 8000; @@ -44,9 +50,20 @@ function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: }; } +/** How long a restored draft waits for the identity that wrote it to load. */ +const IDENTITY_WAIT_MS = 4000; + /** * The composer that was open when the desktop app quit, back on its draft. A draft * that has gone since (sent or deleted elsewhere) means there's nothing to show. + * A server that doesn't answer says nothing about it: the window stays, offers to try + * again, and the draft stays on the list for the next start. + * + * Only through the login the address names, and never without one: ids repeat across + * accounts, so another login could hold someone else's message under this id. + * + * The From stays what it was. With several accounts the other accounts' identities + * load later, and the composer would otherwise start on the default and save that. */ function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { draftId: string; @@ -55,37 +72,92 @@ function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { onReady: (payload: ComposePopoutPayload) => void; }) { const t = useTranslations(); + const tRestore = useTranslations("desktopUi.restore"); const tWindows = useTranslations("pwaWindows"); - const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined) ?? s.client); - const identities = useIdentityStore((s) => s.identities); + const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined)); + const ownIdentities = useIdentityStore((s) => s.identities); + const multi = useProMultiAccountIdentities(); + const identities = multi.enabled ? multi.allIdentities : ownIdentities; + const [draft, setDraft] = useState(null); + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + const [waitOver, setWaitOver] = useState(false); + + // Until the composer takes over, this window still stands for the draft. useEffect(() => { + const key = `restored-${draftId}`; + noteOpenDraft(key, { draftId, localAccountId: accountId, ownerAccountId }); + return () => noteOpenDraft(key, null); + }, [draftId, accountId, ownerAccountId]); + + useEffect(() => { + if (!accountId) { + closePopoutWindow(); + return; + } if (!client) return; let cancelled = false; - void client.getEmail(draftId, ownerAccountId).then((draft) => { + setFailed(false); + void Promise.all([client.getEmail(draftId, ownerAccountId), client.getMailboxes(ownerAccountId)]).then(async ([found, mailboxes]) => { if (cancelled) return; - if (!draft) { + if (!found || !isRestorableDraft(found, mailboxes)) { closePopoutWindow(); return; } - onReady({ - mode: "compose", - initialData: composerDataFromDraft(draft, { - accountId: ownerAccountId, - localAccountId: accountId, - identities, - noSubject: t("email_composer.no_subject"), - }), - }); + const transformed = await emailHooks.onBeforeEditDraft.transform(found); + if (!cancelled) setDraft(transformed); }).catch((error) => { debug.error("Failed to reopen a draft:", error); - if (!cancelled) closePopoutWindow(); + if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; - // Once per client: the draft doesn't change under a window still opening. + }, [client, accountId, draftId, ownerAccountId, attempt]); + + // Back online: try by itself. + useEffect(() => { + if (!failed) return; + const retry = () => setAttempt((n) => n + 1); + window.addEventListener("online", retry); + return () => window.removeEventListener("online", retry); + }, [failed]); + + useEffect(() => { + const timer = setTimeout(() => setWaitOver(true), IDENTITY_WAIT_MS); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!draft) return; + const from = draft.from?.[0]; + const matched = findDraftIdentityIdForLogin(identities, from, accountId); + // The identity may still be on its way: wait for it, then keep the address as it was. + if (!matched && from && !waitOver) return; + const data = composerDataFromDraft(draft, { + accountId: ownerAccountId, + localAccountId: accountId, + identities, + noSubject: t("email_composer.no_subject"), + }); + onReady({ + mode: "compose", + initialData: !matched && from?.email + ? { ...data, fromOverrideEnabled: true, fromOverrideEmail: from.email, fromOverrideName: from.name ?? "" } + : data, + }); + // onReady swaps this component out; `t` changes nothing here. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [client, draftId, ownerAccountId]); + }, [draft, identities, waitOver]); + + if (failed) { + return ( + + + + + ); + } return ; } @@ -205,7 +277,15 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; onUnloadState: (state: ComposerDraftData) => { if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null }); }, - onDraftSaved: () => announceMailChanged(useAuthStore.getState().activeAccountId), + onDraftSaved: (draftId: string) => { + announceMailChanged(useAuthStore.getState().activeAccountId); + // A save makes a new id, and this address is what a reload starts from. + const url = new URL(window.location.href); + if (url.searchParams.has("draft")) { + url.searchParams.set("draft", draftId); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + } + }, }), [handoffId, mainWindow, payload]); return ( diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx index c7841a2b1..4f0ae12ad 100644 --- a/components/popout/popout-message-app.tsx +++ b/components/popout/popout-message-app.tsx @@ -24,7 +24,7 @@ const CHECK_INTERVAL_MS = 60_000; * The id from the address bar: a page the service worker kept for a start * without the network was rendered for another id. */ -function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string } { +function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean } { if (typeof window === "undefined") return { emailId: paramId }; const segments = window.location.pathname.split("/").filter(Boolean); const at = segments.lastIndexOf("message"); @@ -35,7 +35,7 @@ function readLaunch(paramId: string): { emailId: string; accountId?: string; own // A malformed address: the route's own value. } const search = new URLSearchParams(window.location.search); - return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined }; + return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined, fromLink: search.get("via") === "link" }; } type Presence = "present" | "gone"; @@ -96,7 +96,7 @@ function useMessagePresence(emailId: string, ownerAccountId: string | undefined) return { presence, mailboxIds, liveState }; } -function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string; accountId?: string; ownerAccountId?: string }) { +function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean }) { const t = useTranslations("pwaWindows"); const tSidebar = useTranslations("sidebar"); const login = useAuthStore((s) => accountId ?? s.activeAccountId ?? undefined); @@ -194,6 +194,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string onOpenCompose={openCompose} liveState={liveState} onMailChanged={handleMailChanged} + noAutomaticActions={fromLink} className="min-h-0 w-full flex-1" /> @@ -205,7 +206,7 @@ export function PopoutMessageApp({ id }: { id: string }) { const [launch] = useState(() => readLaunch(id)); return ( - + ); } diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index aeaf0f204..4a8adc45f 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -79,6 +79,8 @@ interface ProEmailViewProps { liveState?: Pick | null; /** This view changed the message's flags, for a host other windows should hear from. */ onMailChanged?: () => void; + /** See EmailViewer. */ + noAutomaticActions?: boolean; /** * The message left this view: deleted, archived, moved, or reopened in the * composer. The host decides what that means (close the tab / clear the @@ -134,7 +136,7 @@ function buildReplyContext(email: Email): ProReplyContext { * Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's * reading pane. */ -export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, onClose, className }: ProEmailViewProps) { +export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, onClose, className }: ProEmailViewProps) { const t = useTranslations(); const tNotifications = useTranslations('notifications'); const tRemove = useTranslations('email_list.confirm_remove'); @@ -574,6 +576,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
( const nativeMode = isOpen && mounted && !nativeRefused && nativeMenusAvailable(); const positionRef = useRef(position); positionRef.current = position; + const tDesktop = useTranslations("desktopUi.menu"); + const tDesktopRef = useRef(tDesktop); + tDesktopRef.current = tDesktop; useEffect(() => { setMounted(true); @@ -280,8 +285,15 @@ export const ContextMenu = forwardRef( const place = at ? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP } : undefined; - await showNativeMenu(read, place); - if (!cancelled) onCloseRef.current(); + const outcome = await showNativeMenu(read, root, place, tDesktopRef.current("from_plugins")); + if (cancelled) return; + // "More from plugins": the menu itself, with what only it can show. + if (outcome === "html") { + setNativeRefused(true); + return; + } + if (outcome === "gone") toast.warning(tDesktopRef.current("pick_gone")); + onCloseRef.current(); })(); return () => { cancelled = true; diff --git a/lib/__tests__/outgoing-hosts-mode.test.ts b/lib/__tests__/outgoing-hosts-mode.test.ts index fd4b4d713..182cb26e1 100644 --- a/lib/__tests__/outgoing-hosts-mode.test.ts +++ b/lib/__tests__/outgoing-hosts-mode.test.ts @@ -82,6 +82,10 @@ const probes: Record Promise> = { await GET(new NextRequest('https://webmail.example/api/bimi?domain=probe.org')); return bimiResolve.mock.calls.length > 0; }, + 'spellcheck-dictionaries': async () => { + const { dictionaryDownloadAllowed } = await import('@/lib/desktop/dictionary-download'); + return dictionaryDownloadAllowed(); + }, translate: async () => { const { POST } = await import('@/app/api/translate/route'); await POST(new NextRequest('https://webmail.example/api/translate', { diff --git a/lib/admin/outgoing-hosts-registry.ts b/lib/admin/outgoing-hosts-registry.ts index dae391d33..dc585707f 100644 --- a/lib/admin/outgoing-hosts-registry.ts +++ b/lib/admin/outgoing-hosts-registry.ts @@ -106,6 +106,16 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [ inMode: 'Avatars show no brand logos; /api/bimi answers 403 before any lookup.', lite: false, }, + { + id: 'spellcheck-dictionaries', + host: 'redirector.gvt1.com', + files: ['lib/desktop/dictionary-download.ts'], + side: 'browser', + usedBy: 'Desktop app only: a spellcheck dictionary from Google, for a language the OS has no checker for, after the user said yes when the composer asked. The text being written is not sent.', + off: 'Answer no when the composer asks once. Nothing is fetched until a yes.', + inMode: 'The composer does not ask, and the app has no address to fetch dictionaries from.', + lite: false, + }, { id: 'translate', host: 'api.mymemory.translated.net', diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts index b0215caab..ffa595310 100644 --- a/lib/compose-flush-registry.ts +++ b/lib/compose-flush-registry.ts @@ -14,6 +14,21 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; export type ComposeFlush = () => Promise; +const openedListeners = new Set<() => void>(); + +/** A composer is open in this window. */ +export function composerIsOpen(): boolean { + return flushes.size > 0; +} + +/** Calls `listener` as a composer opens. Returns the unsubscribe. */ +export function onComposerOpened(listener: () => void): () => void { + openedListeners.add(listener); + return () => { + openedListeners.delete(listener); + }; +} + /** Less than the desktop shell waits for a closing window. */ const DESKTOP_FLUSH_MS = 3000; @@ -44,6 +59,7 @@ export function registerComposeFlush(flush: ComposeFlush): () => void { flushes.set(id, flush); for (const waiter of [...waiters]) waiter(flush); syncDesktopFlush(); + for (const listener of [...openedListeners]) listener(); return () => { flushes.delete(id); syncDesktopFlush(); diff --git a/lib/desktop/__tests__/native-ui.test.tsx b/lib/desktop/__tests__/native-ui.test.tsx index 2bab20eb1..58df7bba2 100644 --- a/lib/desktop/__tests__/native-ui.test.tsx +++ b/lib/desktop/__tests__/native-ui.test.tsx @@ -8,6 +8,7 @@ import { readMenu } from '../native-menu'; import { noteOpenDraft, resetOpenDrafts, restoreUrlFor } from '../window-restore'; import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '../preferences'; import { closePopoutWindow, isPopoutPath } from '@/lib/popout/window-role'; +import { toast } from '@/stores/toast-store'; function setBridge(bridge: Partial | undefined) { window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; @@ -124,6 +125,42 @@ describe('an HTML menu shown natively', () => { expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy(); }); + it('a plugin\'s frame keeps the rest native, with a line to the HTML menu', async () => { + const frame = ( +
'; + expect(readMenu(root).unsupported).toBe(true); + }); + + it('a row that went while the menu was up says so, instead of doing nothing', async () => { + const warning = vi.spyOn(toast, 'warning').mockImplementation(() => '' as never); + const reply = vi.fn(); + const { items, onClose } = await openMenu(testMenu(undefined, { reply })); + const row = screen.getAllByRole('menuitem', { hidden: true }).find((el) => el.textContent?.includes('Reply'))!; + row.parentElement!.remove(); + await act(async () => { + answer(items[1].id!); + }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(reply).not.toHaveBeenCalled(); + expect(warning).toHaveBeenCalledWith('pick_gone'); + warning.mockRestore(); + }); + it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => { const root = document.createElement('div'); root.innerHTML = ''; @@ -242,7 +279,7 @@ describe('a pop-out closing itself', () => { describe('window settings', () => { it('both default to on, and stay on this computer', () => { - expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true }); + expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true, dictionaryDownload: 'ask' }); setDesktopPref('restoreWindows', false); resetDesktopPrefsCache(); expect(readDesktopPrefs().restoreWindows).toBe(false); diff --git a/lib/desktop/dictionary-download.ts b/lib/desktop/dictionary-download.ts new file mode 100644 index 000000000..587b7af39 --- /dev/null +++ b/lib/desktop/dictionary-download.ts @@ -0,0 +1,14 @@ +/** + * Spellcheck dictionaries for languages the OS has no checker for. They'd come from + * this Google host, so nothing is fetched until the user says yes in the composer, + * and never in no-third-party mode. The shell starts every session with the address + * pointing nowhere (src/main/spellcheck.ts in the desktop repo). + */ + +import { noThirdPartyTraffic } from '@/lib/third-party-mode'; + +export const DICTIONARY_HOST_URL = 'https://redirector.gvt1.com/edgedl/chrome/dict/'; + +export function dictionaryDownloadAllowed(): boolean { + return !noThirdPartyTraffic(); +} diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts new file mode 100644 index 000000000..5c054dc28 --- /dev/null +++ b/lib/desktop/languages.ts @@ -0,0 +1,128 @@ +/** + * What the shell's menus and spellcheck should speak. The interface language and + * its translations of the menus come from the host, and each open composer adds the + * language its text is in, so spelling is checked in what's being written. + */ + +import { getDesktopBridge } from './bridge'; + +let ui = ''; +let labels: Record | undefined; +const composers = new Map(); + +function push(): void { + const shell = getDesktopBridge()?.shell; + if (!shell?.setLanguages || !ui) return; + shell.setLanguages({ ui, composer: [...new Set(composers.values())], labels }); +} + +export function setDesktopInterfaceLanguage(language: string, translated: Record): void { + ui = language; + labels = translated; + push(); +} + +/** With null, this composer has gone. */ +export function noteComposerLanguage(key: string, language: string | null): void { + if (language) { + if (composers.get(key) === language) return; + composers.set(key, language); + } else if (!composers.delete(key)) { + return; + } + push(); +} + +/** The shell's text in the interface language, by its keys in src/main/strings.ts. */ +export function shellLabels(t: (key: string) => string): Record { + return { + menuFile: t('desktopUi.menu.file'), + menuEdit: t('desktopUi.menu.edit'), + menuView: t('desktopUi.menu.view'), + menuMessage: t('desktopUi.menu.message'), + menuGo: t('desktopUi.menu.go'), + menuWindow: t('desktopUi.menu.window'), + menuHelp: t('desktopUi.menu.help'), + menuNewMessage: t('email_composer.new_message'), + menuNewWindow: t('desktopUi.menu.new_window'), + menuPrint: t('desktopUi.menu.print'), + menuSavePdf: t('desktopUi.menu.save_pdf'), + menuSwitchMode: t('desktopUi.menu.switch_connection'), + menuClose: t('desktopUi.menu.close'), + menuQuit: t('desktopUi.menu.quit'), + menuUndo: t('desktopUi.menu.undo'), + menuRedo: t('desktopUi.menu.redo'), + menuCut: t('desktopUi.menu.cut'), + menuCopy: t('desktopUi.menu.copy'), + menuPaste: t('desktopUi.menu.paste'), + menuSelectAll: t('desktopUi.menu.select_all'), + menuSearch: t('desktopUi.menu.search'), + menuRefresh: t('desktopUi.menu.refresh'), + menuReadingPane: t('desktopUi.menu.reading_pane'), + menuPaneRight: t('desktopUi.menu.pane_right'), + menuPaneBelow: t('desktopUi.menu.pane_below'), + menuPaneOff: t('desktopUi.menu.pane_off'), + menuZoomIn: t('desktopUi.menu.zoom_in'), + menuZoomOut: t('desktopUi.menu.zoom_out'), + menuZoomReset: t('desktopUi.menu.zoom_reset'), + menuFullScreen: t('desktopUi.menu.full_screen'), + menuReply: t('context_menu.reply'), + menuReplyAll: t('context_menu.reply_all'), + menuForward: t('context_menu.forward'), + menuMarkRead: t('context_menu.mark_read'), + menuMarkUnread: t('context_menu.mark_unread'), + menuStar: t('context_menu.star'), + menuMove: t('context_menu.move_to_dialog'), + menuArchive: t('context_menu.archive'), + menuSpam: t('context_menu.mark_as_spam'), + menuDelete: t('context_menu.delete'), + menuOpenInWindow: t('desktopUi.menu.open_in_window'), + menuGoInbox: t('sidebar.mailboxes.inbox'), + menuGoSent: t('sidebar.mailboxes.sent'), + menuGoDrafts: t('sidebar.mailboxes.drafts'), + menuGoNext: t('desktopUi.menu.next_message'), + menuGoPrevious: t('desktopUi.menu.previous_message'), + menuGoNextUnread: t('desktopUi.menu.next_unread'), + menuGoMail: t('desktopUi.menu.mail'), + menuGoCalendar: t('sidebar.calendar'), + menuGoContacts: t('sidebar.contacts'), + menuGoFiles: t('desktopUi.menu.files'), + menuGoBack: t('desktopUi.menu.back'), + menuGoForward: t('desktopUi.menu.forward'), + menuMinimize: t('desktopUi.menu.minimize'), + menuCycle: t('desktopUi.menu.next_window'), + menuShortcuts: t('desktopUi.menu.shortcuts'), + menuDocs: t('desktopUi.menu.docs'), + menuSettings: t('desktopUi.menu.settings'), + menuAbout: t('desktopUi.menu.about'), + contextCut: t('desktopUi.menu.cut'), + contextCopy: t('desktopUi.menu.copy'), + contextPaste: t('desktopUi.menu.paste'), + contextSelectAll: t('desktopUi.menu.select_all'), + contextAddToDictionary: t('desktopUi.context.add_to_dictionary'), + contextNoSuggestions: t('desktopUi.context.no_suggestions'), + contextOpenLink: t('desktopUi.context.open_link'), + contextCopyLink: t('desktopUi.context.copy_link'), + contextCopyAddress: t('desktopUi.context.copy_address'), + contextSaveImage: t('desktopUi.context.save_image'), + contextCopyImage: t('desktopUi.context.copy_image'), + unsavedTitle: t('desktopUi.closing.unsaved_title'), + unsavedDetail: t('desktopUi.closing.unsaved_detail'), + closeAnyway: t('desktopUi.closing.close_anyway'), + unsentTitle: t('desktopUi.closing.unsent_title'), + unsentOne: t('desktopUi.closing.unsent_one'), + unsentMany: t('desktopUi.closing.unsent_many'), + closeNow: t('desktopUi.closing.quit'), + leaveTitle: t('desktopUi.closing.leave_title'), + leaveDetail: t('desktopUi.closing.leave_detail'), + leave: t('desktopUi.closing.leave'), + stay: t('desktopUi.closing.stay'), + }; +} + +/** Test helper. */ +export function resetDesktopLanguages(): void { + ui = ''; + labels = undefined; + composers.clear(); +} diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts index ae8eca8a5..5fac69c9e 100644 --- a/lib/desktop/native-menu.ts +++ b/lib/desktop/native-menu.ts @@ -4,8 +4,9 @@ * natively. A pick clicks the row the HTML menu has for it, so a menu has one list of * entries with one set of handlers and the two can't drift. * - * A menu with something a native one can't show (a plugin's frame, a colour picker, - * a row with no text) stays HTML. So does anything marked data-native-menu="html". + * A menu with something a native one can't show (a colour picker, a row with no text) + * stays HTML. So does anything marked data-native-menu="html". A plugin's frame in the + * menu doesn't: the rest shows natively, with one line that opens the HTML menu. */ import { getDesktopBridge } from './bridge'; @@ -21,10 +22,15 @@ export interface ReadMenu { elements: Map; /** There's something here a native menu can't show. */ unsupported: boolean; + /** A plugin drew something of its own in the menu. */ + hasFrames: boolean; } const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]'; +/** The native line that opens the HTML menu instead. */ +export const HTML_MENU_ITEM = "html-menu"; + function isDisabled(el: HTMLElement): boolean { return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true'; } @@ -40,7 +46,8 @@ function isHidden(el: Element): boolean { export function readMenu(root: HTMLElement): ReadMenu { const elements = new Map(); - let unsupported = root.querySelector('[data-native-menu="html"], iframe, select, textarea, input[type="color"]') !== null; + let unsupported = root.querySelector('[data-native-menu="html"], select, textarea, input[type="color"]') !== null; + let hasFrames = false; let next = 1; const walk = (container: Element, into: DesktopNativeMenuItem[]): void => { @@ -50,6 +57,12 @@ export function readMenu(root: HTMLElement): ReadMenu { into.push({ type: 'separator' }); continue; } + if (child.tagName === 'IFRAME') { + // Only a plugin's own slot has this coming. Anything else stays HTML whole. + if (child.closest('[data-plugin-slot]')) hasFrames = true; + else unsupported = true; + continue; + } if (child.dataset.submenuPanel !== undefined) continue; if (child.dataset.contextMenuHeader !== undefined) { const label = labelOf(child); @@ -105,7 +118,7 @@ export function readMenu(root: HTMLElement): ReadMenu { const items: DesktopNativeMenuItem[] = []; walk(root, items); - return { items, elements, unsupported }; + return { items, elements, unsupported, hasFrames }; } const SETTLE_MS = 30; @@ -127,17 +140,46 @@ export async function readSettledMenu(root: HTMLElement, cancelled: () => boolea return previous; } -/** Shows it and clicks the row picked. Resolves when the menu has gone. */ -export async function showNativeMenu(read: ReadMenu, at?: { x: number; y: number }): Promise { +function labelOfId(items: DesktopNativeMenuItem[], id: string): string | undefined { + for (const item of items) { + if (item.id === id) return item.label; + const below = item.submenu && labelOfId(item.submenu, id); + if (below) return below; + } + return undefined; +} + +export type NativeMenuOutcome = 'done' | 'html' | 'gone'; + +/** + * Shows it and clicks the row picked. Says when the pick found nothing: the page can + * re-render under a menu that stays up (the list refreshing), and a row that's gone + * would otherwise do nothing with no word about it. If the same line is back at the + * same place, that's the one. + */ +export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x: number; y: number }, fromPlugins?: string): Promise { const popup = getDesktopBridge()?.menu?.popup; - if (!popup) return; + if (!popup) return 'done'; + const items = read.hasFrames && fromPlugins + ? [...read.items, { type: 'separator' as const }, { id: HTML_MENU_ITEM, label: fromPlugins }] + : read.items; document.documentElement.dataset.nativeMenuOpen = ''; + let picked: string | null; try { - const picked = await popup(read.items, at); - if (picked) read.elements.get(picked)?.click(); + picked = await popup(items, at); } finally { delete document.documentElement.dataset.nativeMenuOpen; } + if (!picked) return 'done'; + if (picked === HTML_MENU_ITEM) return 'html'; + let row = read.elements.get(picked); + if (!row?.isConnected) { + const again = root.isConnected ? readMenu(root) : null; + row = again && labelOfId(again.items, picked) === labelOfId(read.items, picked) ? again.elements.get(picked) : undefined; + } + if (!row?.isConnected) return 'gone'; + row.click(); + return 'done'; } /** A native menu is up: the window's blur or a stray event says nothing about it. */ diff --git a/lib/desktop/open-link.ts b/lib/desktop/open-link.ts new file mode 100644 index 000000000..96c9c8f6f --- /dev/null +++ b/lib/desktop/open-link.ts @@ -0,0 +1,22 @@ +/** + * "Open Link" in the desktop app's native menu. It has to go where a click on the + * link goes: a mail address to the app's composer, anything else past + * resolveExternalLink, where a plugin can stop it or send it elsewhere. + */ + +import { isOpenableLinkHref } from '@/lib/email-sanitization'; +import { resolveExternalLink } from '@/lib/plugin-hook-result'; +import { requestInternalMailto } from '@/lib/protocol-handlers/mailto'; +import { getDesktopBridge } from './bridge'; + +export async function openLinkFromNativeMenu(href: string, text: string, emailId?: string): Promise { + const shell = getDesktopBridge()?.shell; + if (!shell) return; + if (/^mailto:/i.test(href)) { + if (!requestInternalMailto(href)) shell.openExternal(href); + return; + } + if (!isOpenableLinkHref(href)) return; + const target = await resolveExternalLink({ href, target: '_blank', emailId, text }); + if (target) shell.openExternal(target); +} diff --git a/lib/desktop/preferences.ts b/lib/desktop/preferences.ts index f9894c5c8..7cd499858 100644 --- a/lib/desktop/preferences.ts +++ b/lib/desktop/preferences.ts @@ -13,9 +13,11 @@ export interface DesktopWindowPrefs { openOnDoubleClick: boolean; /** The windows open at quit come back at the next start. */ restoreWindows: boolean; + /** Spellcheck dictionaries from Google, for a language the OS has no checker for. Asked once. */ + dictionaryDownload: 'ask' | 'yes' | 'no'; } -const DEFAULTS: DesktopWindowPrefs = { openOnDoubleClick: true, restoreWindows: true }; +const DEFAULTS: DesktopWindowPrefs = { openOnDoubleClick: true, restoreWindows: true, dictionaryDownload: 'ask' }; let cached: DesktopWindowPrefs | null = null; const listeners = new Set<() => void>(); @@ -32,6 +34,7 @@ export function readDesktopPrefs(): DesktopWindowPrefs { cached = { openOnDoubleClick: typeof stored.openOnDoubleClick === 'boolean' ? stored.openOnDoubleClick : DEFAULTS.openOnDoubleClick, restoreWindows: typeof stored.restoreWindows === 'boolean' ? stored.restoreWindows : DEFAULTS.restoreWindows, + dictionaryDownload: stored.dictionaryDownload === 'yes' || stored.dictionaryDownload === 'no' ? stored.dictionaryDownload : DEFAULTS.dictionaryDownload, }; return cached; } diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index f9eb6109b..485a95ff0 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -126,8 +126,16 @@ export interface DesktopMenuBridge { export interface DesktopShellBridge { /** http, https and mailto only. */ openExternal: (url: string) => void; - /** The shell's menus follow `ui`. Spellcheck follows both. */ - setLanguages?: (languages: { ui: string; composer?: string[] }) => void; + /** The shell's menus follow `ui`, in `labels` (lib/desktop/languages.ts). Spellcheck follows `ui` and `composer`. */ + setLanguages?: (languages: { ui: string; composer?: string[]; labels?: Record }) => void; +} + +export interface DesktopSpellcheckBridge { + /** Languages with neither an OS checker nor a dictionary here. */ + missing: () => Promise; + onMissing: (listener: (language: string) => void) => () => void; + /** The user said yes: dictionaries may come from Google. Never with no-third-party mode on. */ + allowDownload: () => void; } export interface DesktopDialogsBridge { @@ -144,6 +152,8 @@ export interface DesktopDialogsBridge { export interface DesktopClipboardBridge { /** Puts a file on the clipboard, for pasting into a folder. False when that didn't work. */ copyFile: (name: string, data: ArrayBuffer) => Promise; + /** Signed out: nothing copied from that account stays on disk. */ + clearFiles?: () => void; } export type DesktopServerPrepareResult = @@ -281,6 +291,7 @@ export interface BulwarkDesktopBridge { menu?: DesktopMenuBridge; shell?: DesktopShellBridge; dialogs?: DesktopDialogsBridge; + spellcheck?: DesktopSpellcheckBridge; clipboard?: DesktopClipboardBridge; lifecycle?: DesktopLifecycleBridge; servers?: DesktopServersBridge; diff --git a/lib/draft-composer-data.ts b/lib/draft-composer-data.ts index 82eebc52d..8459d2e8d 100644 --- a/lib/draft-composer-data.ts +++ b/lib/draft-composer-data.ts @@ -1,10 +1,25 @@ import type { ComposerDraftData } from '@/components/email/email-composer'; import { findDraftIdentityIdForLogin, type DraftSource } from '@/lib/draft-source'; import { getDraftComposerBody } from '@/lib/email-body-selection'; -import type { Email, Identity } from '@/lib/jmap/types'; +import type { Email, Identity, Mailbox } from '@/lib/jmap/types'; import { messagePriorityFromHeaders } from '@/lib/message-priority'; import { reopenedLabelChoice } from '@/lib/outgoing/classification'; +/** + * Whether a message may reopen as a draft from an id kept since last time. Sending + * keeps the id, and a composer on it would replace, resend or delete the message. A + * draft has `$draft` or sits in Drafts (a send undone comes back without the keyword); + * and in Sent or Trash it's nothing to edit, whatever its keywords say. + */ +export function isRestorableDraft( + draft: Pick, + mailboxes: Pick[], +): boolean { + const roles = Object.keys(draft.mailboxIds ?? {}).map((id) => mailboxes.find((m) => m.id === id || m.originalId === id)?.role); + if (roles.includes('sent') || roles.includes('trash')) return false; + return !!draft.keywords?.$draft || roles.includes('drafts'); +} + /** * What a composer starts from when a saved draft is opened: from the list, or from * last time the desktop app was running. `draft` has its full body. diff --git a/lib/outgoing-hosts.ts b/lib/outgoing-hosts.ts index c58c8608b..91f3f4dc7 100644 --- a/lib/outgoing-hosts.ts +++ b/lib/outgoing-hosts.ts @@ -21,6 +21,7 @@ export const BUILT_IN_HOSTNAMES: readonly string[] = [ 'www.google.com', 'randomuser.me', 'img.freepik.com', + 'redirector.gvt1.com', ]; function hostnameOf(url: string): string | null { diff --git a/locales/ar/common.json b/locales/ar/common.json index b19d3aeeb..f0b57fb42 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6558,6 +6558,85 @@ "save_as": "حفظ باسم…", "copy": "نسخ", "copy_failed": "تعذّر نسخ الملف." + }, + "menu": { + "file": "ملف", + "edit": "تحرير", + "view": "عرض", + "message": "رسالة", + "go": "انتقال", + "window": "نافذة", + "help": "مساعدة", + "new_window": "نافذة جديدة", + "print": "طباعة…", + "save_pdf": "حفظ بصيغة PDF…", + "switch_connection": "تغيير الاتصال…", + "close": "إغلاق", + "quit": "إنهاء", + "undo": "تراجع", + "redo": "إعادة", + "cut": "قص", + "copy": "نسخ", + "paste": "لصق", + "select_all": "تحديد الكل", + "search": "بحث في البريد", + "refresh": "التحقق من البريد الجديد", + "reading_pane": "جزء القراءة", + "pane_right": "على اليمين", + "pane_below": "أسفل القائمة", + "pane_off": "إيقاف", + "zoom_in": "تكبير", + "zoom_out": "تصغير", + "zoom_reset": "الحجم الفعلي", + "full_screen": "ملء الشاشة", + "open_in_window": "فتح في نافذة جديدة", + "next_message": "الرسالة التالية", + "previous_message": "الرسالة السابقة", + "next_unread": "الرسالة التالية غير المقروءة", + "mail": "البريد", + "files": "الملفات", + "back": "رجوع", + "forward": "للأمام", + "minimize": "تصغير", + "next_window": "النافذة التالية", + "shortcuts": "اختصارات لوحة المفاتيح", + "docs": "الوثائق", + "settings": "الإعدادات…", + "about": "حول Bulwark", + "from_plugins": "المزيد من الإضافات…", + "pick_gone": "هذه القائمة قديمة. افتحها مرة أخرى وحاول ثانية." + }, + "context": { + "add_to_dictionary": "إضافة «{word}» إلى القاموس", + "no_suggestions": "لا توجد اقتراحات", + "open_link": "فتح الرابط", + "copy_link": "نسخ الرابط", + "copy_address": "نسخ العنوان", + "save_image": "حفظ الصورة…", + "copy_image": "نسخ الصورة" + }, + "closing": { + "unsaved_title": "تعذّر حفظ مسودة", + "unsaved_detail": "إذا أغلقت الآن، سيضيع ما كتبته منذ آخر حفظ.", + "close_anyway": "إغلاق على أي حال", + "unsent_title": "توجد رسائل لم تُرسل بعد", + "unsent_one": "توجد رسالة واحدة في انتظار الإرسال. ستُرسل في المرة القادمة التي تفتح فيها Bulwark.", + "unsent_many": "توجد {count} رسائل في انتظار الإرسال. ستُرسل في المرة القادمة التي تفتح فيها Bulwark.", + "quit": "إنهاء", + "leave_title": "المغادرة مع تغييرات غير محفوظة؟", + "leave_detail": "لم تُحفظ بعد التغييرات التي أجريتها في هذه الصفحة.", + "leave": "مغادرة", + "stay": "بقاء" + }, + "spellcheck": { + "offer": "يحتاج التدقيق الإملائي بلغة {language} إلى قاموس. يمكن لـ Bulwark تنزيله من Google. لا يُرسل نصك.", + "download": "تنزيل" + }, + "restore": { + "unavailable_title": "تعذّر فتح هذه المسودة الآن", + "unavailable_body": "إنها محفوظة بأمان على الخادم في المسودات. حاول مرة أخرى عند عودة الاتصال.", + "try_again": "إعادة المحاولة", + "close": "إغلاق" } }, "desktop_sign_in": { diff --git a/locales/ca/common.json b/locales/ca/common.json index b4c960e21..556caead6 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6558,6 +6558,85 @@ "save_as": "Desa com a…", "copy": "Copia", "copy_failed": "No s’ha pogut copiar el fitxer." + }, + "menu": { + "file": "Fitxer", + "edit": "Edita", + "view": "Visualitza", + "message": "Missatge", + "go": "Vés-hi", + "window": "Finestra", + "help": "Ajuda", + "new_window": "Finestra nova", + "print": "Imprimeix…", + "save_pdf": "Desa com a PDF…", + "switch_connection": "Canvia de connexió…", + "close": "Tanca", + "quit": "Surt", + "undo": "Desfés", + "redo": "Refés", + "cut": "Retalla", + "copy": "Copia", + "paste": "Enganxa", + "select_all": "Selecciona-ho tot", + "search": "Cerca al correu", + "refresh": "Comprova si hi ha correu nou", + "reading_pane": "Tauler de lectura", + "pane_right": "A la dreta", + "pane_below": "Sota la llista", + "pane_off": "Desactivat", + "zoom_in": "Amplia", + "zoom_out": "Redueix", + "zoom_reset": "Mida real", + "full_screen": "Pantalla completa", + "open_in_window": "Obre en una finestra nova", + "next_message": "Missatge següent", + "previous_message": "Missatge anterior", + "next_unread": "Següent missatge no llegit", + "mail": "Correu", + "files": "Fitxers", + "back": "Enrere", + "forward": "Endavant", + "minimize": "Minimitza", + "next_window": "Finestra següent", + "shortcuts": "Dreceres de teclat", + "docs": "Documentació", + "settings": "Configuració…", + "about": "Quant a Bulwark", + "from_plugins": "Més dels connectors…", + "pick_gone": "Aquest menú estava desfasat. Torneu-lo a obrir i proveu-ho una altra vegada." + }, + "context": { + "add_to_dictionary": "Afegeix «{word}» al diccionari", + "no_suggestions": "Cap suggeriment", + "open_link": "Obre l’enllaç", + "copy_link": "Copia l’enllaç", + "copy_address": "Copia l’adreça", + "save_image": "Desa la imatge…", + "copy_image": "Copia la imatge" + }, + "closing": { + "unsaved_title": "No s’ha pogut desar un esborrany", + "unsaved_detail": "Si tanqueu ara, es perdrà el que heu escrit des de l’últim desament.", + "close_anyway": "Tanca igualment", + "unsent_title": "Hi ha correu sense enviar", + "unsent_one": "Hi ha un missatge pendent d’enviar. Se n’anirà la propera vegada que obriu Bulwark.", + "unsent_many": "Hi ha {count} missatges pendents d’enviar. Se n’aniran la propera vegada que obriu Bulwark.", + "quit": "Surt", + "leave_title": "Voleu sortir amb canvis sense desar?", + "leave_detail": "Els canvis fets en aquesta pàgina encara no s’han desat.", + "leave": "Surt", + "stay": "Queda’t" + }, + "spellcheck": { + "offer": "La revisió ortogràfica en {language} necessita un diccionari. Bulwark el pot baixar de Google. El vostre text no s’envia.", + "download": "Baixa" + }, + "restore": { + "unavailable_title": "Aquest esborrany no es pot obrir ara", + "unavailable_body": "Està segur al servidor, a Esborranys. Torneu-ho a provar quan hi hagi connexió.", + "try_again": "Torna-ho a provar", + "close": "Tanca" } }, "desktop_sign_in": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 28c8b0968..170d6ce85 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6558,6 +6558,85 @@ "save_as": "Uložit jako…", "copy": "Kopírovat", "copy_failed": "Soubor se nepodařilo zkopírovat." + }, + "menu": { + "file": "Soubor", + "edit": "Úpravy", + "view": "Zobrazení", + "message": "Zpráva", + "go": "Přejít", + "window": "Okno", + "help": "Nápověda", + "new_window": "Nové okno", + "print": "Tisk…", + "save_pdf": "Uložit jako PDF…", + "switch_connection": "Změnit připojení…", + "close": "Zavřít", + "quit": "Ukončit", + "undo": "Zpět", + "redo": "Znovu", + "cut": "Vyjmout", + "copy": "Kopírovat", + "paste": "Vložit", + "select_all": "Vybrat vše", + "search": "Hledat v poště", + "refresh": "Zkontrolovat novou poštu", + "reading_pane": "Podokno čtení", + "pane_right": "Vpravo", + "pane_below": "Pod seznamem", + "pane_off": "Vypnuto", + "zoom_in": "Přiblížit", + "zoom_out": "Oddálit", + "zoom_reset": "Skutečná velikost", + "full_screen": "Celá obrazovka", + "open_in_window": "Otevřít v novém okně", + "next_message": "Další zpráva", + "previous_message": "Předchozí zpráva", + "next_unread": "Další nepřečtená zpráva", + "mail": "Pošta", + "files": "Soubory", + "back": "Zpět", + "forward": "Vpřed", + "minimize": "Minimalizovat", + "next_window": "Další okno", + "shortcuts": "Klávesové zkratky", + "docs": "Dokumentace", + "settings": "Nastavení…", + "about": "O aplikaci Bulwark", + "from_plugins": "Více z doplňků…", + "pick_gone": "Tato nabídka byla zastaralá. Otevřete ji znovu a zkuste to ještě jednou." + }, + "context": { + "add_to_dictionary": "Přidat „{word}“ do slovníku", + "no_suggestions": "Žádné návrhy", + "open_link": "Otevřít odkaz", + "copy_link": "Kopírovat odkaz", + "copy_address": "Kopírovat adresu", + "save_image": "Uložit obrázek…", + "copy_image": "Kopírovat obrázek" + }, + "closing": { + "unsaved_title": "Koncept se nepodařilo uložit", + "unsaved_detail": "Pokud nyní okno zavřete, přijdete o to, co jste napsali od posledního uložení.", + "close_anyway": "Přesto zavřít", + "unsent_title": "Některá pošta ještě nebyla odeslána", + "unsent_one": "Jedna zpráva čeká na odeslání. Odejde při dalším spuštění Bulwark.", + "unsent_many": "Počet zpráv čekajících na odeslání: {count}. Odejdou při dalším spuštění Bulwark.", + "quit": "Ukončit", + "leave_title": "Odejít s neuloženými změnami?", + "leave_detail": "Změny na této stránce zatím nejsou uloženy.", + "leave": "Odejít", + "stay": "Zůstat" + }, + "spellcheck": { + "offer": "Kontrola pravopisu pro jazyk {language} potřebuje slovník. Bulwark ho může stáhnout od společnosti Google. Váš text se neodesílá.", + "download": "Stáhnout" + }, + "restore": { + "unavailable_title": "Tento koncept se teď nedá otevřít", + "unavailable_body": "Je v bezpečí na serveru ve složce Koncepty. Zkuste to znovu, až se obnoví připojení.", + "try_again": "Zkusit znovu", + "close": "Zavřít" } }, "desktop_sign_in": { diff --git a/locales/da/common.json b/locales/da/common.json index e47034d0e..0122e60fe 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6558,6 +6558,85 @@ "save_as": "Gem som…", "copy": "Kopiér", "copy_failed": "Filen kunne ikke kopieres." + }, + "menu": { + "file": "Filer", + "edit": "Rediger", + "view": "Vis", + "message": "Besked", + "go": "Gå", + "window": "Vindue", + "help": "Hjælp", + "new_window": "Nyt vindue", + "print": "Udskriv…", + "save_pdf": "Gem som PDF…", + "switch_connection": "Skift forbindelse…", + "close": "Luk", + "quit": "Afslut", + "undo": "Fortryd", + "redo": "Gentag", + "cut": "Klip", + "copy": "Kopiér", + "paste": "Indsæt", + "select_all": "Markér alt", + "search": "Søg i mail", + "refresh": "Hent nye mails", + "reading_pane": "Læserude", + "pane_right": "Til højre", + "pane_below": "Under listen", + "pane_off": "Fra", + "zoom_in": "Zoom ind", + "zoom_out": "Zoom ud", + "zoom_reset": "Faktisk størrelse", + "full_screen": "Fuld skærm", + "open_in_window": "Åbn i nyt vindue", + "next_message": "Næste besked", + "previous_message": "Forrige besked", + "next_unread": "Næste ulæste besked", + "mail": "Mail", + "files": "Filer", + "back": "Tilbage", + "forward": "Frem", + "minimize": "Minimer", + "next_window": "Næste vindue", + "shortcuts": "Tastaturgenveje", + "docs": "Dokumentation", + "settings": "Indstillinger…", + "about": "Om Bulwark", + "from_plugins": "Mere fra plugins…", + "pick_gone": "Den menu var forældet. Åbn den igen, og prøv en gang til." + }, + "context": { + "add_to_dictionary": "Føj “{word}” til ordbogen", + "no_suggestions": "Ingen forslag", + "open_link": "Åbn link", + "copy_link": "Kopiér link", + "copy_address": "Kopiér adresse", + "save_image": "Gem billede…", + "copy_image": "Kopiér billede" + }, + "closing": { + "unsaved_title": "En kladde kunne ikke gemmes", + "unsaved_detail": "Hvis du lukker nu, går det tabt, du har skrevet siden sidste gang, du gemte.", + "close_anyway": "Luk alligevel", + "unsent_title": "Nogle mails er ikke sendt endnu", + "unsent_one": "Én besked venter på at blive sendt. Den bliver sendt, næste gang du åbner Bulwark.", + "unsent_many": "{count} beskeder venter på at blive sendt. De bliver sendt, næste gang du åbner Bulwark.", + "quit": "Afslut", + "leave_title": "Forlad siden med ugemte ændringer?", + "leave_detail": "Dine ændringer på denne side er ikke gemt endnu.", + "leave": "Forlad", + "stay": "Bliv" + }, + "spellcheck": { + "offer": "Stavekontrol på {language} kræver en ordbog. Bulwark kan hente den fra Google. Din tekst bliver ikke sendt.", + "download": "Hent" + }, + "restore": { + "unavailable_title": "Denne kladde kan ikke åbnes lige nu", + "unavailable_body": "Den er sikker på serveren i Kladder. Prøv igen, når forbindelsen er tilbage.", + "try_again": "Prøv igen", + "close": "Luk" } }, "desktop_sign_in": { diff --git a/locales/de/common.json b/locales/de/common.json index ca3134e5d..fa98785e4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6558,6 +6558,85 @@ "save_as": "Speichern unter…", "copy": "Kopieren", "copy_failed": "Die Datei konnte nicht kopiert werden." + }, + "menu": { + "file": "Datei", + "edit": "Bearbeiten", + "view": "Ansicht", + "message": "Nachricht", + "go": "Gehe zu", + "window": "Fenster", + "help": "Hilfe", + "new_window": "Neues Fenster", + "print": "Drucken…", + "save_pdf": "Als PDF sichern…", + "switch_connection": "Verbindung wechseln…", + "close": "Schließen", + "quit": "Beenden", + "undo": "Rückgängig", + "redo": "Wiederholen", + "cut": "Ausschneiden", + "copy": "Kopieren", + "paste": "Einfügen", + "select_all": "Alles auswählen", + "search": "E-Mails durchsuchen", + "refresh": "Neue E-Mails abrufen", + "reading_pane": "Lesebereich", + "pane_right": "Rechts", + "pane_below": "Unter der Liste", + "pane_off": "Aus", + "zoom_in": "Vergrößern", + "zoom_out": "Verkleinern", + "zoom_reset": "Tatsächliche Größe", + "full_screen": "Vollbild", + "open_in_window": "In neuem Fenster öffnen", + "next_message": "Nächste Nachricht", + "previous_message": "Vorherige Nachricht", + "next_unread": "Nächste ungelesene Nachricht", + "mail": "E-Mail", + "files": "Dateien", + "back": "Zurück", + "forward": "Vorwärts", + "minimize": "Minimieren", + "next_window": "Nächstes Fenster", + "shortcuts": "Tastenkürzel", + "docs": "Dokumentation", + "settings": "Einstellungen…", + "about": "Über Bulwark", + "from_plugins": "Mehr von Plugins…", + "pick_gone": "Dieses Menü war veraltet. Öffnen Sie es erneut und versuchen Sie es noch einmal." + }, + "context": { + "add_to_dictionary": "„{word}“ zum Wörterbuch hinzufügen", + "no_suggestions": "Keine Vorschläge", + "open_link": "Link öffnen", + "copy_link": "Link kopieren", + "copy_address": "Adresse kopieren", + "save_image": "Bild sichern…", + "copy_image": "Bild kopieren" + }, + "closing": { + "unsaved_title": "Ein Entwurf konnte nicht gespeichert werden", + "unsaved_detail": "Wenn Sie jetzt schließen, geht verloren, was Sie seit dem letzten Speichern geschrieben haben.", + "close_anyway": "Trotzdem schließen", + "unsent_title": "Einige E-Mails wurden noch nicht gesendet", + "unsent_one": "Eine Nachricht wartet auf den Versand. Sie wird beim nächsten Start von Bulwark gesendet.", + "unsent_many": "{count} Nachrichten warten auf den Versand. Sie werden beim nächsten Start von Bulwark gesendet.", + "quit": "Beenden", + "leave_title": "Mit ungespeicherten Änderungen verlassen?", + "leave_detail": "Ihre Änderungen auf dieser Seite sind noch nicht gespeichert.", + "leave": "Verlassen", + "stay": "Bleiben" + }, + "spellcheck": { + "offer": "Die Rechtschreibprüfung für {language} benötigt ein Wörterbuch. Bulwark kann es von Google herunterladen. Ihr Text wird nicht gesendet.", + "download": "Herunterladen" + }, + "restore": { + "unavailable_title": "Dieser Entwurf kann gerade nicht geöffnet werden", + "unavailable_body": "Er ist auf dem Server im Ordner „Entwürfe“ gesichert. Versuchen Sie es erneut, sobald die Verbindung wieder besteht.", + "try_again": "Erneut versuchen", + "close": "Schließen" } }, "desktop_sign_in": { diff --git a/locales/en/common.json b/locales/en/common.json index e178107a0..6112c3ddd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6558,6 +6558,85 @@ "save_as": "Save as…", "copy": "Copy", "copy_failed": "The file could not be copied." + }, + "menu": { + "file": "File", + "edit": "Edit", + "view": "View", + "message": "Message", + "go": "Go", + "window": "Window", + "help": "Help", + "new_window": "New Window", + "print": "Print…", + "save_pdf": "Save as PDF…", + "switch_connection": "Change Connection…", + "close": "Close", + "quit": "Quit", + "undo": "Undo", + "redo": "Redo", + "cut": "Cut", + "copy": "Copy", + "paste": "Paste", + "select_all": "Select All", + "search": "Search Mail", + "refresh": "Check for New Mail", + "reading_pane": "Reading Pane", + "pane_right": "On the Right", + "pane_below": "Below the List", + "pane_off": "Off", + "zoom_in": "Zoom In", + "zoom_out": "Zoom Out", + "zoom_reset": "Actual Size", + "full_screen": "Full Screen", + "open_in_window": "Open in New Window", + "next_message": "Next Message", + "previous_message": "Previous Message", + "next_unread": "Next Unread Message", + "mail": "Mail", + "files": "Files", + "back": "Back", + "forward": "Forward", + "minimize": "Minimize", + "next_window": "Next Window", + "shortcuts": "Keyboard Shortcuts", + "docs": "Documentation", + "settings": "Settings…", + "about": "About Bulwark", + "from_plugins": "More from plugins…", + "pick_gone": "That menu was out of date. Open it again and try once more." + }, + "context": { + "add_to_dictionary": "Add \"{word}\" to Dictionary", + "no_suggestions": "No Suggestions", + "open_link": "Open Link", + "copy_link": "Copy Link", + "copy_address": "Copy Address", + "save_image": "Save Image…", + "copy_image": "Copy Image" + }, + "closing": { + "unsaved_title": "A draft could not be saved", + "unsaved_detail": "If you close now, what you wrote since the last save is lost.", + "close_anyway": "Close anyway", + "unsent_title": "Some mail has not been sent yet", + "unsent_one": "One message is waiting to be sent. It goes out the next time you open Bulwark.", + "unsent_many": "{count} messages are waiting to be sent. They go out the next time you open Bulwark.", + "quit": "Quit", + "leave_title": "Leave with unsaved changes?", + "leave_detail": "What you changed on this page is not saved yet.", + "leave": "Leave", + "stay": "Stay" + }, + "spellcheck": { + "offer": "Spell checking in {language} needs a dictionary. Bulwark can download it from Google. Your text isn't sent.", + "download": "Download" + }, + "restore": { + "unavailable_title": "This draft can't be opened right now", + "unavailable_body": "It's safe on the server, in Drafts. Try again when the connection is back.", + "try_again": "Try again", + "close": "Close" } }, "desktop_sign_in": { diff --git a/locales/es/common.json b/locales/es/common.json index 92ae5cfc1..7b1a9627c 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6558,6 +6558,85 @@ "save_as": "Guardar como…", "copy": "Copiar", "copy_failed": "No se pudo copiar el archivo." + }, + "menu": { + "file": "Archivo", + "edit": "Edición", + "view": "Visualización", + "message": "Mensaje", + "go": "Ir", + "window": "Ventana", + "help": "Ayuda", + "new_window": "Nueva ventana", + "print": "Imprimir…", + "save_pdf": "Guardar como PDF…", + "switch_connection": "Cambiar de conexión…", + "close": "Cerrar", + "quit": "Salir", + "undo": "Deshacer", + "redo": "Rehacer", + "cut": "Cortar", + "copy": "Copiar", + "paste": "Pegar", + "select_all": "Seleccionar todo", + "search": "Buscar en el correo", + "refresh": "Comprobar correo nuevo", + "reading_pane": "Panel de lectura", + "pane_right": "A la derecha", + "pane_below": "Debajo de la lista", + "pane_off": "Desactivado", + "zoom_in": "Acercar", + "zoom_out": "Alejar", + "zoom_reset": "Tamaño real", + "full_screen": "Pantalla completa", + "open_in_window": "Abrir en una ventana nueva", + "next_message": "Mensaje siguiente", + "previous_message": "Mensaje anterior", + "next_unread": "Siguiente mensaje no leído", + "mail": "Correo", + "files": "Archivos", + "back": "Atrás", + "forward": "Adelante", + "minimize": "Minimizar", + "next_window": "Ventana siguiente", + "shortcuts": "Métodos abreviados de teclado", + "docs": "Documentación", + "settings": "Configuración…", + "about": "Acerca de Bulwark", + "from_plugins": "Más de los complementos…", + "pick_gone": "Ese menú estaba desactualizado. Ábralo de nuevo e inténtelo otra vez." + }, + "context": { + "add_to_dictionary": "Añadir «{word}» al diccionario", + "no_suggestions": "Sin sugerencias", + "open_link": "Abrir enlace", + "copy_link": "Copiar enlace", + "copy_address": "Copiar dirección", + "save_image": "Guardar imagen…", + "copy_image": "Copiar imagen" + }, + "closing": { + "unsaved_title": "No se pudo guardar un borrador", + "unsaved_detail": "Si cierra ahora, se perderá lo que escribió desde el último guardado.", + "close_anyway": "Cerrar de todos modos", + "unsent_title": "Hay correo sin enviar", + "unsent_one": "Hay un mensaje esperando para enviarse. Saldrá la próxima vez que abra Bulwark.", + "unsent_many": "Hay {count} mensajes esperando para enviarse. Saldrán la próxima vez que abra Bulwark.", + "quit": "Salir", + "leave_title": "¿Salir con cambios sin guardar?", + "leave_detail": "Los cambios que hizo en esta página aún no se han guardado.", + "leave": "Salir", + "stay": "Quedarse" + }, + "spellcheck": { + "offer": "La revisión ortográfica en {language} necesita un diccionario. Bulwark puede descargarlo de Google. Su texto no se envía.", + "download": "Descargar" + }, + "restore": { + "unavailable_title": "Este borrador no se puede abrir ahora", + "unavailable_body": "Está a salvo en el servidor, en Borradores. Inténtelo de nuevo cuando vuelva la conexión.", + "try_again": "Reintentar", + "close": "Cerrar" } }, "desktop_sign_in": { diff --git a/locales/et/common.json b/locales/et/common.json index af5abde97..5933106bb 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6558,6 +6558,85 @@ "save_as": "Salvesta nimega…", "copy": "Kopeeri", "copy_failed": "Faili ei saanud kopeerida." + }, + "menu": { + "file": "Fail", + "edit": "Redigeerimine", + "view": "Vaade", + "message": "Kiri", + "go": "Mine", + "window": "Aken", + "help": "Spikker", + "new_window": "Uus aken", + "print": "Prindi…", + "save_pdf": "Salvesta PDF-ina…", + "switch_connection": "Vaheta ühendust…", + "close": "Sulge", + "quit": "Välju", + "undo": "Võta tagasi", + "redo": "Tee uuesti", + "cut": "Lõika", + "copy": "Kopeeri", + "paste": "Kleebi", + "select_all": "Vali kõik", + "search": "Otsi kirjadest", + "refresh": "Kontrolli uusi kirju", + "reading_pane": "Lugemisvaade", + "pane_right": "Paremal", + "pane_below": "Loendi all", + "pane_off": "Väljas", + "zoom_in": "Suurenda", + "zoom_out": "Vähenda", + "zoom_reset": "Tegelik suurus", + "full_screen": "Täisekraan", + "open_in_window": "Ava uues aknas", + "next_message": "Järgmine kiri", + "previous_message": "Eelmine kiri", + "next_unread": "Järgmine lugemata kiri", + "mail": "Post", + "files": "Failid", + "back": "Tagasi", + "forward": "Edasi", + "minimize": "Minimeeri", + "next_window": "Järgmine aken", + "shortcuts": "Klaviatuuri otseteed", + "docs": "Dokumentatsioon", + "settings": "Seaded…", + "about": "Teave Bulwarki kohta", + "from_plugins": "Veel pistikprogrammidelt…", + "pick_gone": "See menüü oli aegunud. Avage see uuesti ja proovige veel kord." + }, + "context": { + "add_to_dictionary": "Lisa „{word}” sõnastikku", + "no_suggestions": "Soovitusi pole", + "open_link": "Ava link", + "copy_link": "Kopeeri link", + "copy_address": "Kopeeri aadress", + "save_image": "Salvesta pilt…", + "copy_image": "Kopeeri pilt" + }, + "closing": { + "unsaved_title": "Mustandit ei õnnestunud salvestada", + "unsaved_detail": "Kui sulgete nüüd, läheb kaduma see, mille olete pärast viimast salvestamist kirjutanud.", + "close_anyway": "Sulge siiski", + "unsent_title": "Mõni kiri on veel saatmata", + "unsent_one": "Üks kiri ootab saatmist. See läheb teele järgmine kord, kui Bulwarki avate.", + "unsent_many": "{count} kirja ootab saatmist. Need lähevad teele järgmine kord, kui Bulwarki avate.", + "quit": "Välju", + "leave_title": "Lahkuda salvestamata muudatustega?", + "leave_detail": "Selle lehe muudatused pole veel salvestatud.", + "leave": "Lahku", + "stay": "Jää" + }, + "spellcheck": { + "offer": "Õigekirjakontroll keelele {language} vajab sõnastikku. Bulwark saab selle Google’ilt alla laadida. Teie teksti ei saadeta.", + "download": "Laadi alla" + }, + "restore": { + "unavailable_title": "Seda mustandit ei saa praegu avada", + "unavailable_body": "See on serveris kaustas Mustandid turvaliselt alles. Proovige uuesti, kui ühendus on tagasi.", + "try_again": "Proovi uuesti", + "close": "Sulge" } }, "desktop_sign_in": { diff --git a/locales/fa/common.json b/locales/fa/common.json index b25da5a9b..e7ed42adf 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6558,6 +6558,85 @@ "save_as": "ذخیره با نام…", "copy": "کپی", "copy_failed": "فایل کپی نشد." + }, + "menu": { + "file": "پرونده", + "edit": "ویرایش", + "view": "نما", + "message": "پیام", + "go": "برو به", + "window": "پنجره", + "help": "راهنما", + "new_window": "پنجره جدید", + "print": "چاپ…", + "save_pdf": "ذخیره به‌صورت PDF…", + "switch_connection": "تغییر اتصال…", + "close": "بستن", + "quit": "خروج", + "undo": "واگرد", + "redo": "ازنو", + "cut": "برش", + "copy": "رونوشت", + "paste": "چسباندن", + "select_all": "انتخاب همه", + "search": "جستجو در نامه‌ها", + "refresh": "بررسی نامه‌های جدید", + "reading_pane": "قاب خواندن", + "pane_right": "سمت راست", + "pane_below": "زیر فهرست", + "pane_off": "خاموش", + "zoom_in": "بزرگ‌نمایی", + "zoom_out": "کوچک‌نمایی", + "zoom_reset": "اندازه واقعی", + "full_screen": "تمام‌صفحه", + "open_in_window": "باز کردن در پنجره جدید", + "next_message": "پیام بعدی", + "previous_message": "پیام قبلی", + "next_unread": "پیام خوانده‌نشده بعدی", + "mail": "نامه", + "files": "پرونده‌ها", + "back": "بازگشت", + "forward": "جلو", + "minimize": "کوچک کردن", + "next_window": "پنجره بعدی", + "shortcuts": "میان‌برهای صفحه‌کلید", + "docs": "مستندات", + "settings": "تنظیمات…", + "about": "درباره Bulwark", + "from_plugins": "موارد بیشتر از افزونه‌ها…", + "pick_gone": "این منو قدیمی شده بود. دوباره آن را باز کنید و یک بار دیگر امتحان کنید." + }, + "context": { + "add_to_dictionary": "افزودن «{word}» به واژه‌نامه", + "no_suggestions": "پیشنهادی نیست", + "open_link": "باز کردن پیوند", + "copy_link": "رونوشت پیوند", + "copy_address": "رونوشت نشانی", + "save_image": "ذخیره تصویر…", + "copy_image": "رونوشت تصویر" + }, + "closing": { + "unsaved_title": "یک پیش‌نویس ذخیره نشد", + "unsaved_detail": "اگر اکنون ببندید، آنچه از آخرین ذخیره نوشته‌اید از بین می‌رود.", + "close_anyway": "با این حال بسته شود", + "unsent_title": "برخی نامه‌ها هنوز ارسال نشده‌اند", + "unsent_one": "یک پیام در انتظار ارسال است. دفعه بعد که Bulwark را باز کنید ارسال می‌شود.", + "unsent_many": "{count} پیام در انتظار ارسال هستند. دفعه بعد که Bulwark را باز کنید ارسال می‌شوند.", + "quit": "خروج", + "leave_title": "با تغییرات ذخیره‌نشده خارج می‌شوید؟", + "leave_detail": "تغییراتی که در این صفحه داده‌اید هنوز ذخیره نشده است.", + "leave": "خروج", + "stay": "ماندن" + }, + "spellcheck": { + "offer": "بررسی املای {language} به واژه‌نامه نیاز دارد. Bulwark می‌تواند آن را از Google دانلود کند. متن شما ارسال نمی‌شود.", + "download": "دانلود" + }, + "restore": { + "unavailable_title": "اکنون نمی‌توان این پیش‌نویس را باز کرد", + "unavailable_body": "این پیش‌نویس در پوشه پیش‌نویس‌ها روی سرور سالم است. وقتی اتصال برگشت دوباره امتحان کنید.", + "try_again": "تلاش دوباره", + "close": "بستن" } }, "desktop_sign_in": { diff --git a/locales/fr/common.json b/locales/fr/common.json index a283bf2a2..db3f72230 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6558,6 +6558,85 @@ "save_as": "Enregistrer sous…", "copy": "Copier", "copy_failed": "Impossible de copier le fichier." + }, + "menu": { + "file": "Fichier", + "edit": "Édition", + "view": "Présentation", + "message": "Message", + "go": "Aller", + "window": "Fenêtre", + "help": "Aide", + "new_window": "Nouvelle fenêtre", + "print": "Imprimer…", + "save_pdf": "Enregistrer au format PDF…", + "switch_connection": "Changer de connexion…", + "close": "Fermer", + "quit": "Quitter", + "undo": "Annuler", + "redo": "Rétablir", + "cut": "Couper", + "copy": "Copier", + "paste": "Coller", + "select_all": "Tout sélectionner", + "search": "Rechercher dans les messages", + "refresh": "Relever le courrier", + "reading_pane": "Volet de lecture", + "pane_right": "À droite", + "pane_below": "Sous la liste", + "pane_off": "Désactivé", + "zoom_in": "Zoom avant", + "zoom_out": "Zoom arrière", + "zoom_reset": "Taille réelle", + "full_screen": "Plein écran", + "open_in_window": "Ouvrir dans une nouvelle fenêtre", + "next_message": "Message suivant", + "previous_message": "Message précédent", + "next_unread": "Prochain message non lu", + "mail": "Courrier", + "files": "Fichiers", + "back": "Précédent", + "forward": "Suivant", + "minimize": "Réduire", + "next_window": "Fenêtre suivante", + "shortcuts": "Raccourcis clavier", + "docs": "Documentation", + "settings": "Paramètres…", + "about": "À propos de Bulwark", + "from_plugins": "Plus d’options des modules…", + "pick_gone": "Ce menu n’était plus à jour. Rouvrez-le et réessayez." + }, + "context": { + "add_to_dictionary": "Ajouter « {word} » au dictionnaire", + "no_suggestions": "Aucune suggestion", + "open_link": "Ouvrir le lien", + "copy_link": "Copier le lien", + "copy_address": "Copier l’adresse", + "save_image": "Enregistrer l’image…", + "copy_image": "Copier l’image" + }, + "closing": { + "unsaved_title": "Un brouillon n’a pas pu être enregistré", + "unsaved_detail": "Si vous fermez maintenant, ce que vous avez écrit depuis le dernier enregistrement sera perdu.", + "close_anyway": "Fermer quand même", + "unsent_title": "Des messages n’ont pas encore été envoyés", + "unsent_one": "Un message attend d’être envoyé. Il partira à la prochaine ouverture de Bulwark.", + "unsent_many": "{count} messages attendent d’être envoyés. Ils partiront à la prochaine ouverture de Bulwark.", + "quit": "Quitter", + "leave_title": "Quitter sans enregistrer les modifications ?", + "leave_detail": "Vos modifications sur cette page ne sont pas encore enregistrées.", + "leave": "Quitter la page", + "stay": "Rester" + }, + "spellcheck": { + "offer": "La correction orthographique en {language} nécessite un dictionnaire. Bulwark peut le télécharger depuis Google. Votre texte n’est pas envoyé.", + "download": "Télécharger" + }, + "restore": { + "unavailable_title": "Ce brouillon ne peut pas être ouvert pour le moment", + "unavailable_body": "Il est en sécurité sur le serveur, dans Brouillons. Réessayez quand la connexion sera rétablie.", + "try_again": "Réessayer", + "close": "Fermer" } }, "desktop_sign_in": { diff --git a/locales/he/common.json b/locales/he/common.json index 8ca0148b4..cf7d7cafc 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6559,6 +6559,85 @@ "save_as": "שמירה בשם…", "copy": "העתקה", "copy_failed": "לא ניתן היה להעתיק את הקובץ." + }, + "menu": { + "file": "קובץ", + "edit": "עריכה", + "view": "תצוגה", + "message": "הודעה", + "go": "מעבר", + "window": "חלון", + "help": "עזרה", + "new_window": "חלון חדש", + "print": "הדפסה…", + "save_pdf": "שמירה כ-PDF…", + "switch_connection": "שינוי חיבור…", + "close": "סגירה", + "quit": "יציאה", + "undo": "ביטול", + "redo": "ביצוע חוזר", + "cut": "גזירה", + "copy": "העתקה", + "paste": "הדבקה", + "select_all": "בחירת הכול", + "search": "חיפוש בדואר", + "refresh": "בדיקת דואר חדש", + "reading_pane": "חלונית קריאה", + "pane_right": "מימין", + "pane_below": "מתחת לרשימה", + "pane_off": "כבויה", + "zoom_in": "הגדלה", + "zoom_out": "הקטנה", + "zoom_reset": "גודל בפועל", + "full_screen": "מסך מלא", + "open_in_window": "פתיחה בחלון חדש", + "next_message": "ההודעה הבאה", + "previous_message": "ההודעה הקודמת", + "next_unread": "ההודעה הבאה שלא נקראה", + "mail": "דואר", + "files": "קבצים", + "back": "חזרה", + "forward": "קדימה", + "minimize": "מזעור", + "next_window": "החלון הבא", + "shortcuts": "קיצורי מקלדת", + "docs": "תיעוד", + "settings": "הגדרות…", + "about": "מידע על Bulwark", + "from_plugins": "עוד מהתוספים…", + "pick_gone": "התפריט הזה היה מיושן. פתחו אותו שוב ונסו פעם נוספת." + }, + "context": { + "add_to_dictionary": "הוספת “{word}” למילון", + "no_suggestions": "אין הצעות", + "open_link": "פתיחת קישור", + "copy_link": "העתקת קישור", + "copy_address": "העתקת כתובת", + "save_image": "שמירת תמונה…", + "copy_image": "העתקת תמונה" + }, + "closing": { + "unsaved_title": "לא ניתן היה לשמור טיוטה", + "unsaved_detail": "אם תסגרו עכשיו, מה שכתבתם מאז השמירה האחרונה יאבד.", + "close_anyway": "לסגור בכל זאת", + "unsent_title": "יש הודעות שעדיין לא נשלחו", + "unsent_one": "הודעה אחת ממתינה לשליחה. היא תישלח בפעם הבאה שתפתחו את Bulwark.", + "unsent_many": "{count} הודעות ממתינות לשליחה. הן יישלחו בפעם הבאה שתפתחו את Bulwark.", + "quit": "יציאה", + "leave_title": "לעזוב עם שינויים שלא נשמרו?", + "leave_detail": "השינויים שביצעתם בדף הזה עדיין לא נשמרו.", + "leave": "עזיבה", + "stay": "להישאר" + }, + "spellcheck": { + "offer": "בדיקת איות ב{language} דורשת מילון. Bulwark יכול להוריד אותו מ-Google. הטקסט שלכם לא נשלח.", + "download": "הורדה" + }, + "restore": { + "unavailable_title": "אי אפשר לפתוח את הטיוטה הזאת כרגע", + "unavailable_body": "היא שמורה בשרת, בתיקיית הטיוטות. נסו שוב כשהחיבור יחזור.", + "try_again": "ניסיון חוזר", + "close": "סגירה" } }, "desktop_sign_in": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 0459a57b5..8de7b9d63 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6558,6 +6558,85 @@ "save_as": "Mentés másként…", "copy": "Másolás", "copy_failed": "A fájlt nem sikerült másolni." + }, + "menu": { + "file": "Fájl", + "edit": "Szerkesztés", + "view": "Nézet", + "message": "Üzenet", + "go": "Ugrás", + "window": "Ablak", + "help": "Súgó", + "new_window": "Új ablak", + "print": "Nyomtatás…", + "save_pdf": "Mentés PDF-ként…", + "switch_connection": "Kapcsolat váltása…", + "close": "Bezárás", + "quit": "Kilépés", + "undo": "Visszavonás", + "redo": "Ismétlés", + "cut": "Kivágás", + "copy": "Másolás", + "paste": "Beillesztés", + "select_all": "Összes kijelölése", + "search": "Keresés a levelek között", + "refresh": "Új levelek lekérése", + "reading_pane": "Olvasópanel", + "pane_right": "Jobbra", + "pane_below": "A lista alatt", + "pane_off": "Ki", + "zoom_in": "Nagyítás", + "zoom_out": "Kicsinyítés", + "zoom_reset": "Tényleges méret", + "full_screen": "Teljes képernyő", + "open_in_window": "Megnyitás új ablakban", + "next_message": "Következő üzenet", + "previous_message": "Előző üzenet", + "next_unread": "Következő olvasatlan üzenet", + "mail": "Levelek", + "files": "Fájlok", + "back": "Vissza", + "forward": "Előre", + "minimize": "Minimalizálás", + "next_window": "Következő ablak", + "shortcuts": "Billentyűparancsok", + "docs": "Dokumentáció", + "settings": "Beállítások…", + "about": "A Bulwark névjegye", + "from_plugins": "Továbbiak a bővítményektől…", + "pick_gone": "Ez a menü elavult volt. Nyissa meg újra, és próbálja meg még egyszer." + }, + "context": { + "add_to_dictionary": "„{word}” hozzáadása a szótárhoz", + "no_suggestions": "Nincs javaslat", + "open_link": "Hivatkozás megnyitása", + "copy_link": "Hivatkozás másolása", + "copy_address": "Cím másolása", + "save_image": "Kép mentése…", + "copy_image": "Kép másolása" + }, + "closing": { + "unsaved_title": "Egy piszkozatot nem sikerült menteni", + "unsaved_detail": "Ha most bezárja, elvész, amit az utolsó mentés óta írt.", + "close_anyway": "Bezárás mégis", + "unsent_title": "Néhány levél még nincs elküldve", + "unsent_one": "Egy üzenet küldésre vár. A Bulwark következő megnyitásakor elmegy.", + "unsent_many": "{count} üzenet küldésre vár. A Bulwark következő megnyitásakor elmennek.", + "quit": "Kilépés", + "leave_title": "Elhagyja az oldalt mentetlen módosításokkal?", + "leave_detail": "Az oldalon végzett módosítások még nincsenek mentve.", + "leave": "Elhagyás", + "stay": "Maradás" + }, + "spellcheck": { + "offer": "A(z) {language} nyelvű helyesírás-ellenőrzéshez szótár kell. A Bulwark letöltheti a Google-tól. A szövege nem kerül elküldésre.", + "download": "Letöltés" + }, + "restore": { + "unavailable_title": "Ez a piszkozat most nem nyitható meg", + "unavailable_body": "Biztonságban van a kiszolgálón, a Piszkozatok mappában. Próbálja újra, ha visszatért a kapcsolat.", + "try_again": "Újrapróbálás", + "close": "Bezárás" } }, "desktop_sign_in": { diff --git a/locales/it/common.json b/locales/it/common.json index 95db2212c..a5a66b6ab 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6558,6 +6558,85 @@ "save_as": "Salva con nome…", "copy": "Copia", "copy_failed": "Impossibile copiare il file." + }, + "menu": { + "file": "File", + "edit": "Modifica", + "view": "Vista", + "message": "Messaggio", + "go": "Vai", + "window": "Finestra", + "help": "Aiuto", + "new_window": "Nuova finestra", + "print": "Stampa…", + "save_pdf": "Salva come PDF…", + "switch_connection": "Cambia connessione…", + "close": "Chiudi", + "quit": "Esci", + "undo": "Annulla", + "redo": "Ripeti", + "cut": "Taglia", + "copy": "Copia", + "paste": "Incolla", + "select_all": "Seleziona tutto", + "search": "Cerca nella posta", + "refresh": "Controlla nuova posta", + "reading_pane": "Pannello di lettura", + "pane_right": "A destra", + "pane_below": "Sotto l’elenco", + "pane_off": "Disattivato", + "zoom_in": "Ingrandisci", + "zoom_out": "Riduci", + "zoom_reset": "Dimensione effettiva", + "full_screen": "Schermo intero", + "open_in_window": "Apri in una nuova finestra", + "next_message": "Messaggio successivo", + "previous_message": "Messaggio precedente", + "next_unread": "Prossimo messaggio non letto", + "mail": "Posta", + "files": "File", + "back": "Indietro", + "forward": "Avanti", + "minimize": "Riduci a icona", + "next_window": "Finestra successiva", + "shortcuts": "Scorciatoie da tastiera", + "docs": "Documentazione", + "settings": "Impostazioni…", + "about": "Informazioni su Bulwark", + "from_plugins": "Altro dai plugin…", + "pick_gone": "Questo menu non era aggiornato. Riaprilo e riprova." + }, + "context": { + "add_to_dictionary": "Aggiungi «{word}» al dizionario", + "no_suggestions": "Nessun suggerimento", + "open_link": "Apri link", + "copy_link": "Copia link", + "copy_address": "Copia indirizzo", + "save_image": "Salva immagine…", + "copy_image": "Copia immagine" + }, + "closing": { + "unsaved_title": "Impossibile salvare una bozza", + "unsaved_detail": "Se chiudi ora, quello che hai scritto dall’ultimo salvataggio andrà perso.", + "close_anyway": "Chiudi comunque", + "unsent_title": "Alcuni messaggi non sono ancora stati inviati", + "unsent_one": "Un messaggio è in attesa di invio. Partirà alla prossima apertura di Bulwark.", + "unsent_many": "{count} messaggi sono in attesa di invio. Partiranno alla prossima apertura di Bulwark.", + "quit": "Esci", + "leave_title": "Uscire con modifiche non salvate?", + "leave_detail": "Le modifiche fatte in questa pagina non sono ancora salvate.", + "leave": "Esci", + "stay": "Rimani" + }, + "spellcheck": { + "offer": "Il controllo ortografico in {language} richiede un dizionario. Bulwark può scaricarlo da Google. Il tuo testo non viene inviato.", + "download": "Scarica" + }, + "restore": { + "unavailable_title": "Questa bozza non può essere aperta ora", + "unavailable_body": "È al sicuro sul server, in Bozze. Riprova quando la connessione sarà tornata.", + "try_again": "Riprova", + "close": "Chiudi" } }, "desktop_sign_in": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 78070a2f8..47229b06d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6558,6 +6558,85 @@ "save_as": "名前を付けて保存…", "copy": "コピー", "copy_failed": "ファイルをコピーできませんでした。" + }, + "menu": { + "file": "ファイル", + "edit": "編集", + "view": "表示", + "message": "メッセージ", + "go": "移動", + "window": "ウインドウ", + "help": "ヘルプ", + "new_window": "新規ウインドウ", + "print": "印刷…", + "save_pdf": "PDFとして保存…", + "switch_connection": "接続を変更…", + "close": "閉じる", + "quit": "終了", + "undo": "取り消す", + "redo": "やり直す", + "cut": "切り取り", + "copy": "コピー", + "paste": "貼り付け", + "select_all": "すべてを選択", + "search": "メールを検索", + "refresh": "新着メールを確認", + "reading_pane": "閲覧ウインドウ", + "pane_right": "右側", + "pane_below": "リストの下", + "pane_off": "オフ", + "zoom_in": "拡大", + "zoom_out": "縮小", + "zoom_reset": "実際のサイズ", + "full_screen": "フルスクリーン", + "open_in_window": "新規ウインドウで開く", + "next_message": "次のメッセージ", + "previous_message": "前のメッセージ", + "next_unread": "次の未読メッセージ", + "mail": "メール", + "files": "ファイル", + "back": "戻る", + "forward": "進む", + "minimize": "最小化", + "next_window": "次のウインドウ", + "shortcuts": "キーボードショートカット", + "docs": "ドキュメント", + "settings": "設定…", + "about": "Bulwarkについて", + "from_plugins": "プラグインのその他の項目…", + "pick_gone": "このメニューは古くなっています。もう一度開いて、やり直してください。" + }, + "context": { + "add_to_dictionary": "「{word}」を辞書に追加", + "no_suggestions": "候補なし", + "open_link": "リンクを開く", + "copy_link": "リンクをコピー", + "copy_address": "アドレスをコピー", + "save_image": "画像を保存…", + "copy_image": "画像をコピー" + }, + "closing": { + "unsaved_title": "下書きを保存できませんでした", + "unsaved_detail": "今閉じると、最後に保存した後に書いた内容は失われます。", + "close_anyway": "このまま閉じる", + "unsent_title": "未送信のメールがあります", + "unsent_one": "1件のメッセージが送信を待っています。次にBulwarkを開いたときに送信されます。", + "unsent_many": "{count}件のメッセージが送信を待っています。次にBulwarkを開いたときに送信されます。", + "quit": "終了", + "leave_title": "保存されていない変更を残して移動しますか?", + "leave_detail": "このページで行った変更はまだ保存されていません。", + "leave": "移動する", + "stay": "とどまる" + }, + "spellcheck": { + "offer": "{language}のスペルチェックには辞書が必要です。BulwarkはGoogleから辞書をダウンロードできます。入力した文章は送信されません。", + "download": "ダウンロード" + }, + "restore": { + "unavailable_title": "この下書きは今は開けません", + "unavailable_body": "下書きはサーバーの「下書き」に安全に保管されています。接続が戻ってから、もう一度お試しください。", + "try_again": "再試行", + "close": "閉じる" } }, "desktop_sign_in": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 96a87153f..ea74e31b0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6558,6 +6558,85 @@ "save_as": "다른 이름으로 저장…", "copy": "복사", "copy_failed": "파일을 복사할 수 없습니다." + }, + "menu": { + "file": "파일", + "edit": "편집", + "view": "보기", + "message": "메시지", + "go": "이동", + "window": "창", + "help": "도움말", + "new_window": "새 창", + "print": "인쇄…", + "save_pdf": "PDF로 저장…", + "switch_connection": "연결 변경…", + "close": "닫기", + "quit": "종료", + "undo": "실행 취소", + "redo": "다시 실행", + "cut": "잘라내기", + "copy": "복사", + "paste": "붙여넣기", + "select_all": "모두 선택", + "search": "메일 검색", + "refresh": "새 메일 확인", + "reading_pane": "읽기 창", + "pane_right": "오른쪽", + "pane_below": "목록 아래", + "pane_off": "끄기", + "zoom_in": "확대", + "zoom_out": "축소", + "zoom_reset": "실제 크기", + "full_screen": "전체 화면", + "open_in_window": "새 창에서 열기", + "next_message": "다음 메시지", + "previous_message": "이전 메시지", + "next_unread": "다음 읽지 않은 메시지", + "mail": "메일", + "files": "파일", + "back": "뒤로", + "forward": "앞으로", + "minimize": "최소화", + "next_window": "다음 창", + "shortcuts": "키보드 단축키", + "docs": "문서", + "settings": "설정…", + "about": "Bulwark 정보", + "from_plugins": "플러그인 더 보기…", + "pick_gone": "이 메뉴는 오래된 것입니다. 다시 열어 한 번 더 시도해 주세요." + }, + "context": { + "add_to_dictionary": "“{word}”을(를) 사전에 추가", + "no_suggestions": "추천 없음", + "open_link": "링크 열기", + "copy_link": "링크 복사", + "copy_address": "주소 복사", + "save_image": "이미지 저장…", + "copy_image": "이미지 복사" + }, + "closing": { + "unsaved_title": "임시 보관 메일을 저장하지 못했습니다", + "unsaved_detail": "지금 닫으면 마지막으로 저장한 이후에 작성한 내용이 사라집니다.", + "close_anyway": "그래도 닫기", + "unsent_title": "아직 보내지 않은 메일이 있습니다", + "unsent_one": "메시지 1개가 전송을 기다리고 있습니다. 다음에 Bulwark를 열 때 전송됩니다.", + "unsent_many": "메시지 {count}개가 전송을 기다리고 있습니다. 다음에 Bulwark를 열 때 전송됩니다.", + "quit": "종료", + "leave_title": "저장하지 않은 변경 사항이 있습니다. 나가시겠습니까?", + "leave_detail": "이 페이지에서 변경한 내용이 아직 저장되지 않았습니다.", + "leave": "나가기", + "stay": "머무르기" + }, + "spellcheck": { + "offer": "{language} 맞춤법 검사에는 사전이 필요합니다. Bulwark가 Google에서 내려받을 수 있습니다. 입력한 텍스트는 전송되지 않습니다.", + "download": "내려받기" + }, + "restore": { + "unavailable_title": "지금은 이 임시 보관 메일을 열 수 없습니다", + "unavailable_body": "서버의 임시 보관함에 안전하게 있습니다. 연결이 돌아오면 다시 시도해 주세요.", + "try_again": "다시 시도", + "close": "닫기" } }, "desktop_sign_in": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 1ca156d61..c78516d51 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6558,6 +6558,85 @@ "save_as": "Saglabāt kā…", "copy": "Kopēt", "copy_failed": "Failu neizdevās nokopēt." + }, + "menu": { + "file": "Fails", + "edit": "Rediģēt", + "view": "Skats", + "message": "Ziņojums", + "go": "Doties uz", + "window": "Logs", + "help": "Palīdzība", + "new_window": "Jauns logs", + "print": "Drukāt…", + "save_pdf": "Saglabāt kā PDF…", + "switch_connection": "Mainīt savienojumu…", + "close": "Aizvērt", + "quit": "Iziet", + "undo": "Atsaukt", + "redo": "Atkārtot", + "cut": "Izgriezt", + "copy": "Kopēt", + "paste": "Ielīmēt", + "select_all": "Atlasīt visu", + "search": "Meklēt pastā", + "refresh": "Pārbaudīt jaunu pastu", + "reading_pane": "Lasīšanas rūts", + "pane_right": "Labajā pusē", + "pane_below": "Zem saraksta", + "pane_off": "Izslēgta", + "zoom_in": "Tuvināt", + "zoom_out": "Tālināt", + "zoom_reset": "Faktiskais izmērs", + "full_screen": "Pilnekrāna režīms", + "open_in_window": "Atvērt jaunā logā", + "next_message": "Nākamais ziņojums", + "previous_message": "Iepriekšējais ziņojums", + "next_unread": "Nākamais nelasītais ziņojums", + "mail": "Pasts", + "files": "Faili", + "back": "Atpakaļ", + "forward": "Uz priekšu", + "minimize": "Minimizēt", + "next_window": "Nākamais logs", + "shortcuts": "Tastatūras īsinājumtaustiņi", + "docs": "Dokumentācija", + "settings": "Iestatījumi…", + "about": "Par Bulwark", + "from_plugins": "Vairāk no spraudņiem…", + "pick_gone": "Šī izvēlne bija novecojusi. Atveriet to vēlreiz un mēģiniet vēlreiz." + }, + "context": { + "add_to_dictionary": "Pievienot “{word}” vārdnīcai", + "no_suggestions": "Nav ieteikumu", + "open_link": "Atvērt saiti", + "copy_link": "Kopēt saiti", + "copy_address": "Kopēt adresi", + "save_image": "Saglabāt attēlu…", + "copy_image": "Kopēt attēlu" + }, + "closing": { + "unsaved_title": "Melnrakstu neizdevās saglabāt", + "unsaved_detail": "Ja aizvērsiet tagad, kas uzrakstīts kopš pēdējās saglabāšanas, tiks zaudēts.", + "close_anyway": "Tomēr aizvērt", + "unsent_title": "Daļa pasta vēl nav nosūtīta", + "unsent_one": "Viens ziņojums gaida nosūtīšanu. Tas tiks nosūtīts nākamreiz, kad atvērsiet Bulwark.", + "unsent_many": "Ziņojumi, kas gaida nosūtīšanu: {count}. Tie tiks nosūtīti nākamreiz, kad atvērsiet Bulwark.", + "quit": "Iziet", + "leave_title": "Pamest lapu ar nesaglabātām izmaiņām?", + "leave_detail": "Šajā lapā veiktās izmaiņas vēl nav saglabātas.", + "leave": "Pamest", + "stay": "Palikt" + }, + "spellcheck": { + "offer": "Pareizrakstības pārbaudei valodā {language} nepieciešama vārdnīca. Bulwark to var lejupielādēt no Google. Jūsu teksts netiek sūtīts.", + "download": "Lejupielādēt" + }, + "restore": { + "unavailable_title": "Šo melnrakstu pašlaik nevar atvērt", + "unavailable_body": "Tas ir drošībā serverī, mapē Melnraksti. Mēģiniet vēlreiz, kad savienojums būs atjaunots.", + "try_again": "Mēģināt vēlreiz", + "close": "Aizvērt" } }, "desktop_sign_in": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 52cc39b48..955bb2cc8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6558,6 +6558,85 @@ "save_as": "Өөрөөр хадгалах…", "copy": "Хуулах", "copy_failed": "Файлыг хуулж чадсангүй." + }, + "menu": { + "file": "Файл", + "edit": "Засах", + "view": "Харах", + "message": "Зурвас", + "go": "Очих", + "window": "Цонх", + "help": "Тусламж", + "new_window": "Шинэ цонх", + "print": "Хэвлэх…", + "save_pdf": "PDF хэлбэрээр хадгалах…", + "switch_connection": "Холболт солих…", + "close": "Хаах", + "quit": "Гарах", + "undo": "Буцаах", + "redo": "Дахин хийх", + "cut": "Хайчлах", + "copy": "Хуулах", + "paste": "Буулгах", + "select_all": "Бүгдийг сонгох", + "search": "Захидлаас хайх", + "refresh": "Шинэ захидал шалгах", + "reading_pane": "Унших хэсэг", + "pane_right": "Баруун талд", + "pane_below": "Жагсаалтын доор", + "pane_off": "Идэвхгүй", + "zoom_in": "Томруулах", + "zoom_out": "Жижигрүүлэх", + "zoom_reset": "Бодит хэмжээ", + "full_screen": "Бүтэн дэлгэц", + "open_in_window": "Шинэ цонхонд нээх", + "next_message": "Дараагийн зурвас", + "previous_message": "Өмнөх зурвас", + "next_unread": "Дараагийн уншаагүй зурвас", + "mail": "Имэйл", + "files": "Файлууд", + "back": "Буцах", + "forward": "Урагш", + "minimize": "Жижигрүүлэх", + "next_window": "Дараагийн цонх", + "shortcuts": "Гарын товчлол", + "docs": "Баримт бичиг", + "settings": "Тохиргоо…", + "about": "Bulwark-ийн тухай", + "from_plugins": "Залгаснуудын бусад зүйл…", + "pick_gone": "Энэ цэс хуучирсан байна. Дахин нээгээд нэг удаа оролдоно уу." + }, + "context": { + "add_to_dictionary": "“{word}”-ийг толь бичигт нэмэх", + "no_suggestions": "Санал алга", + "open_link": "Холбоос нээх", + "copy_link": "Холбоос хуулах", + "copy_address": "Хаяг хуулах", + "save_image": "Зураг хадгалах…", + "copy_image": "Зураг хуулах" + }, + "closing": { + "unsaved_title": "Ноорогийг хадгалж чадсангүй", + "unsaved_detail": "Одоо хаавал сүүлд хадгалснаас хойш бичсэн зүйл тань алдагдана.", + "close_anyway": "Ямартай ч хаах", + "unsent_title": "Зарим захидал хараахан илгээгдээгүй байна", + "unsent_one": "Нэг зурвас илгээгдэхийг хүлээж байна. Bulwark-ийг дараа нээхэд илгээгдэнэ.", + "unsent_many": "{count} зурвас илгээгдэхийг хүлээж байна. Bulwark-ийг дараа нээхэд илгээгдэнэ.", + "quit": "Гарах", + "leave_title": "Хадгалаагүй өөрчлөлттэй гарах уу?", + "leave_detail": "Энэ хуудсанд хийсэн өөрчлөлт тань хараахан хадгалагдаагүй байна.", + "leave": "Гарах", + "stay": "Үлдэх" + }, + "spellcheck": { + "offer": "{language} хэлний үсгийн алдаа шалгагчид толь бичиг хэрэгтэй. Bulwark үүнийг Google-ээс татаж авч чадна. Таны бичвэр илгээгдэхгүй.", + "download": "Татах" + }, + "restore": { + "unavailable_title": "Энэ ноорогийг одоо нээх боломжгүй", + "unavailable_body": "Ноорог серверийн Ноорог хавтсанд аюулгүй байна. Холболт сэргэхэд дахин оролдоно уу.", + "try_again": "Дахин оролдох", + "close": "Хаах" } }, "desktop_sign_in": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 0d7083171..a7ebdcb30 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6558,6 +6558,85 @@ "save_as": "Lagre som…", "copy": "Kopier", "copy_failed": "Filen kunne ikke kopieres." + }, + "menu": { + "file": "Fil", + "edit": "Rediger", + "view": "Vis", + "message": "Melding", + "go": "Gå", + "window": "Vindu", + "help": "Hjelp", + "new_window": "Nytt vindu", + "print": "Skriv ut…", + "save_pdf": "Lagre som PDF…", + "switch_connection": "Bytt tilkobling…", + "close": "Lukk", + "quit": "Avslutt", + "undo": "Angre", + "redo": "Gjør om", + "cut": "Klipp ut", + "copy": "Kopier", + "paste": "Lim inn", + "select_all": "Velg alt", + "search": "Søk i e-post", + "refresh": "Se etter ny e-post", + "reading_pane": "Leserute", + "pane_right": "Til høyre", + "pane_below": "Under listen", + "pane_off": "Av", + "zoom_in": "Zoom inn", + "zoom_out": "Zoom ut", + "zoom_reset": "Faktisk størrelse", + "full_screen": "Fullskjerm", + "open_in_window": "Åpne i nytt vindu", + "next_message": "Neste melding", + "previous_message": "Forrige melding", + "next_unread": "Neste uleste melding", + "mail": "E-post", + "files": "Filer", + "back": "Tilbake", + "forward": "Fremover", + "minimize": "Minimer", + "next_window": "Neste vindu", + "shortcuts": "Tastatursnarveier", + "docs": "Dokumentasjon", + "settings": "Innstillinger…", + "about": "Om Bulwark", + "from_plugins": "Mer fra utvidelser…", + "pick_gone": "Den menyen var utdatert. Åpne den på nytt og prøv igjen." + }, + "context": { + "add_to_dictionary": "Legg til «{word}» i ordboken", + "no_suggestions": "Ingen forslag", + "open_link": "Åpne lenke", + "copy_link": "Kopier lenke", + "copy_address": "Kopier adresse", + "save_image": "Lagre bilde…", + "copy_image": "Kopier bilde" + }, + "closing": { + "unsaved_title": "Et utkast kunne ikke lagres", + "unsaved_detail": "Hvis du lukker nå, går det du har skrevet siden forrige lagring tapt.", + "close_anyway": "Lukk likevel", + "unsent_title": "Noe e-post er ikke sendt ennå", + "unsent_one": "Én melding venter på å bli sendt. Den sendes neste gang du åpner Bulwark.", + "unsent_many": "{count} meldinger venter på å bli sendt. De sendes neste gang du åpner Bulwark.", + "quit": "Avslutt", + "leave_title": "Forlate siden med ulagrede endringer?", + "leave_detail": "Endringene dine på denne siden er ikke lagret ennå.", + "leave": "Forlat", + "stay": "Bli" + }, + "spellcheck": { + "offer": "Stavekontroll for {language} trenger en ordbok. Bulwark kan laste den ned fra Google. Teksten din blir ikke sendt.", + "download": "Last ned" + }, + "restore": { + "unavailable_title": "Dette utkastet kan ikke åpnes akkurat nå", + "unavailable_body": "Det er trygt på serveren, i Utkast. Prøv igjen når tilkoblingen er tilbake.", + "try_again": "Prøv igjen", + "close": "Lukk" } }, "desktop_sign_in": { diff --git a/locales/nl/common.json b/locales/nl/common.json index b0e75ada3..7dfec60f3 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6558,6 +6558,85 @@ "save_as": "Opslaan als…", "copy": "Kopiëren", "copy_failed": "Het bestand kon niet worden gekopieerd." + }, + "menu": { + "file": "Bestand", + "edit": "Bewerken", + "view": "Weergave", + "message": "Bericht", + "go": "Ga", + "window": "Venster", + "help": "Help", + "new_window": "Nieuw venster", + "print": "Afdrukken…", + "save_pdf": "Bewaar als pdf…", + "switch_connection": "Verbinding wijzigen…", + "close": "Sluit", + "quit": "Stop", + "undo": "Herstel", + "redo": "Opnieuw", + "cut": "Knip", + "copy": "Kopieer", + "paste": "Plak", + "select_all": "Selecteer alles", + "search": "Zoek in e-mail", + "refresh": "Controleer op nieuwe e-mail", + "reading_pane": "Leesvenster", + "pane_right": "Rechts", + "pane_below": "Onder de lijst", + "pane_off": "Uit", + "zoom_in": "Zoom in", + "zoom_out": "Zoom uit", + "zoom_reset": "Werkelijke grootte", + "full_screen": "Volledig scherm", + "open_in_window": "Open in nieuw venster", + "next_message": "Volgend bericht", + "previous_message": "Vorig bericht", + "next_unread": "Volgend ongelezen bericht", + "mail": "E-mail", + "files": "Bestanden", + "back": "Terug", + "forward": "Vooruit", + "minimize": "Minimaliseer", + "next_window": "Volgend venster", + "shortcuts": "Toetsenbordsneltoetsen", + "docs": "Documentatie", + "settings": "Instellingen…", + "about": "Over Bulwark", + "from_plugins": "Meer van plug-ins…", + "pick_gone": "Dat menu was verouderd. Open het opnieuw en probeer het nog eens." + }, + "context": { + "add_to_dictionary": "Voeg ‘{word}’ toe aan woordenboek", + "no_suggestions": "Geen suggesties", + "open_link": "Open link", + "copy_link": "Kopieer link", + "copy_address": "Kopieer adres", + "save_image": "Bewaar afbeelding…", + "copy_image": "Kopieer afbeelding" + }, + "closing": { + "unsaved_title": "Een concept kon niet worden bewaard", + "unsaved_detail": "Als u nu sluit, gaat verloren wat u sinds de laatste keer bewaren hebt geschreven.", + "close_anyway": "Toch sluiten", + "unsent_title": "Er is e-mail die nog niet is verzonden", + "unsent_one": "Eén bericht wacht om te worden verzonden. Het wordt verzonden de volgende keer dat u Bulwark opent.", + "unsent_many": "{count} berichten wachten om te worden verzonden. Ze worden verzonden de volgende keer dat u Bulwark opent.", + "quit": "Stop", + "leave_title": "Verlaten met niet-bewaarde wijzigingen?", + "leave_detail": "Uw wijzigingen op deze pagina zijn nog niet bewaard.", + "leave": "Verlaat", + "stay": "Blijf" + }, + "spellcheck": { + "offer": "Voor spellingcontrole in het {language} is een woordenboek nodig. Bulwark kan dit downloaden van Google. Uw tekst wordt niet verzonden.", + "download": "Download" + }, + "restore": { + "unavailable_title": "Dit concept kan nu niet worden geopend", + "unavailable_body": "Het staat veilig op de server, in Concepten. Probeer het opnieuw zodra de verbinding terug is.", + "try_again": "Probeer opnieuw", + "close": "Sluit" } }, "desktop_sign_in": { diff --git a/locales/pl/common.json b/locales/pl/common.json index eb4f9f8fc..628f0113e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6558,6 +6558,85 @@ "save_as": "Zapisz jako…", "copy": "Kopiuj", "copy_failed": "Nie udało się skopiować pliku." + }, + "menu": { + "file": "Plik", + "edit": "Edycja", + "view": "Widok", + "message": "Wiadomość", + "go": "Przejdź", + "window": "Okno", + "help": "Pomoc", + "new_window": "Nowe okno", + "print": "Drukuj…", + "save_pdf": "Zapisz jako PDF…", + "switch_connection": "Zmień połączenie…", + "close": "Zamknij", + "quit": "Zakończ", + "undo": "Cofnij", + "redo": "Ponów", + "cut": "Wytnij", + "copy": "Kopiuj", + "paste": "Wklej", + "select_all": "Zaznacz wszystko", + "search": "Szukaj w poczcie", + "refresh": "Sprawdź nową pocztę", + "reading_pane": "Okienko podglądu", + "pane_right": "Po prawej", + "pane_below": "Pod listą", + "pane_off": "Wyłączone", + "zoom_in": "Powiększ", + "zoom_out": "Pomniejsz", + "zoom_reset": "Rzeczywisty rozmiar", + "full_screen": "Pełny ekran", + "open_in_window": "Otwórz w nowym oknie", + "next_message": "Następna wiadomość", + "previous_message": "Poprzednia wiadomość", + "next_unread": "Następna nieprzeczytana wiadomość", + "mail": "Poczta", + "files": "Pliki", + "back": "Wstecz", + "forward": "Dalej", + "minimize": "Minimalizuj", + "next_window": "Następne okno", + "shortcuts": "Skróty klawiszowe", + "docs": "Dokumentacja", + "settings": "Ustawienia…", + "about": "O programie Bulwark", + "from_plugins": "Więcej z wtyczek…", + "pick_gone": "To menu było nieaktualne. Otwórz je ponownie i spróbuj jeszcze raz." + }, + "context": { + "add_to_dictionary": "Dodaj „{word}” do słownika", + "no_suggestions": "Brak podpowiedzi", + "open_link": "Otwórz łącze", + "copy_link": "Kopiuj łącze", + "copy_address": "Kopiuj adres", + "save_image": "Zapisz obraz…", + "copy_image": "Kopiuj obraz" + }, + "closing": { + "unsaved_title": "Nie udało się zapisać wersji roboczej", + "unsaved_detail": "Jeśli zamkniesz teraz, utracisz wszystko, co zostało napisane od ostatniego zapisu.", + "close_anyway": "Zamknij mimo to", + "unsent_title": "Część poczty nie została jeszcze wysłana", + "unsent_one": "Jedna wiadomość czeka na wysłanie. Zostanie wysłana przy następnym uruchomieniu Bulwark.", + "unsent_many": "Wiadomości czekające na wysłanie: {count}. Zostaną wysłane przy następnym uruchomieniu Bulwark.", + "quit": "Zakończ", + "leave_title": "Opuścić stronę bez zapisania zmian?", + "leave_detail": "Zmiany wprowadzone na tej stronie nie zostały jeszcze zapisane.", + "leave": "Opuść", + "stay": "Zostań" + }, + "spellcheck": { + "offer": "Sprawdzanie pisowni dla języka {language} wymaga słownika. Bulwark może go pobrać od Google. Twój tekst nie jest wysyłany.", + "download": "Pobierz" + }, + "restore": { + "unavailable_title": "Nie można teraz otworzyć tej wersji roboczej", + "unavailable_body": "Jest bezpieczna na serwerze, w folderze Wersje robocze. Spróbuj ponownie, gdy połączenie wróci.", + "try_again": "Spróbuj ponownie", + "close": "Zamknij" } }, "desktop_sign_in": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 5235f5317..933c7141a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6558,6 +6558,85 @@ "save_as": "Guardar como…", "copy": "Copiar", "copy_failed": "Não foi possível copiar o ficheiro." + }, + "menu": { + "file": "Ficheiro", + "edit": "Editar", + "view": "Ver", + "message": "Mensagem", + "go": "Ir", + "window": "Janela", + "help": "Ajuda", + "new_window": "Nova janela", + "print": "Imprimir…", + "save_pdf": "Guardar como PDF…", + "switch_connection": "Mudar de ligação…", + "close": "Fechar", + "quit": "Sair", + "undo": "Anular", + "redo": "Refazer", + "cut": "Cortar", + "copy": "Copiar", + "paste": "Colar", + "select_all": "Selecionar tudo", + "search": "Pesquisar no correio", + "refresh": "Verificar novo correio", + "reading_pane": "Painel de leitura", + "pane_right": "À direita", + "pane_below": "Abaixo da lista", + "pane_off": "Desativado", + "zoom_in": "Ampliar", + "zoom_out": "Reduzir", + "zoom_reset": "Tamanho real", + "full_screen": "Ecrã inteiro", + "open_in_window": "Abrir numa nova janela", + "next_message": "Mensagem seguinte", + "previous_message": "Mensagem anterior", + "next_unread": "Próxima mensagem por ler", + "mail": "Correio", + "files": "Ficheiros", + "back": "Anterior", + "forward": "Seguinte", + "minimize": "Minimizar", + "next_window": "Janela seguinte", + "shortcuts": "Atalhos de teclado", + "docs": "Documentação", + "settings": "Definições…", + "about": "Acerca do Bulwark", + "from_plugins": "Mais dos plugins…", + "pick_gone": "Esse menu estava desatualizado. Abra-o de novo e tente outra vez." + }, + "context": { + "add_to_dictionary": "Adicionar «{word}» ao dicionário", + "no_suggestions": "Sem sugestões", + "open_link": "Abrir ligação", + "copy_link": "Copiar ligação", + "copy_address": "Copiar endereço", + "save_image": "Guardar imagem…", + "copy_image": "Copiar imagem" + }, + "closing": { + "unsaved_title": "Não foi possível guardar um rascunho", + "unsaved_detail": "Se fechar agora, perde o que escreveu desde a última gravação.", + "close_anyway": "Fechar mesmo assim", + "unsent_title": "Há mensagens por enviar", + "unsent_one": "Uma mensagem aguarda envio. Será enviada na próxima vez que abrir o Bulwark.", + "unsent_many": "{count} mensagens aguardam envio. Serão enviadas na próxima vez que abrir o Bulwark.", + "quit": "Sair", + "leave_title": "Sair com alterações não guardadas?", + "leave_detail": "As alterações feitas nesta página ainda não foram guardadas.", + "leave": "Sair", + "stay": "Ficar" + }, + "spellcheck": { + "offer": "A verificação ortográfica em {language} precisa de um dicionário. O Bulwark pode transferi-lo da Google. O seu texto não é enviado.", + "download": "Transferir" + }, + "restore": { + "unavailable_title": "Este rascunho não pode ser aberto agora", + "unavailable_body": "Está seguro no servidor, em Rascunhos. Tente novamente quando a ligação voltar.", + "try_again": "Tentar novamente", + "close": "Fechar" } }, "desktop_sign_in": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 5076823a5..f4e721732 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6558,6 +6558,85 @@ "save_as": "Salvează ca…", "copy": "Copiază", "copy_failed": "Fișierul nu a putut fi copiat." + }, + "menu": { + "file": "Fișier", + "edit": "Editare", + "view": "Vizualizare", + "message": "Mesaj", + "go": "Salt la", + "window": "Fereastră", + "help": "Ajutor", + "new_window": "Fereastră nouă", + "print": "Imprimare…", + "save_pdf": "Salvare ca PDF…", + "switch_connection": "Schimbare conexiune…", + "close": "Închidere", + "quit": "Ieșire", + "undo": "Anulare", + "redo": "Refacere", + "cut": "Decupare", + "copy": "Copiere", + "paste": "Lipire", + "select_all": "Selectare totală", + "search": "Căutare în e-mail", + "refresh": "Verificare e-mailuri noi", + "reading_pane": "Panou de citire", + "pane_right": "În dreapta", + "pane_below": "Sub listă", + "pane_off": "Dezactivat", + "zoom_in": "Mărire", + "zoom_out": "Micșorare", + "zoom_reset": "Dimensiune reală", + "full_screen": "Ecran complet", + "open_in_window": "Deschidere în fereastră nouă", + "next_message": "Mesajul următor", + "previous_message": "Mesajul anterior", + "next_unread": "Următorul mesaj necitit", + "mail": "E-mail", + "files": "Fișiere", + "back": "Înapoi", + "forward": "Înainte", + "minimize": "Minimizare", + "next_window": "Fereastra următoare", + "shortcuts": "Comenzi rapide de la tastatură", + "docs": "Documentație", + "settings": "Setări…", + "about": "Despre Bulwark", + "from_plugins": "Mai multe de la pluginuri…", + "pick_gone": "Meniul era depășit. Deschideți-l din nou și încercați încă o dată." + }, + "context": { + "add_to_dictionary": "Adăugare „{word}” în dicționar", + "no_suggestions": "Fără sugestii", + "open_link": "Deschidere link", + "copy_link": "Copiere link", + "copy_address": "Copiere adresă", + "save_image": "Salvare imagine…", + "copy_image": "Copiere imagine" + }, + "closing": { + "unsaved_title": "O ciornă nu a putut fi salvată", + "unsaved_detail": "Dacă închideți acum, ce ați scris de la ultima salvare se pierde.", + "close_anyway": "Închidere oricum", + "unsent_title": "Unele mesaje nu au fost trimise încă", + "unsent_one": "Un mesaj așteaptă să fie trimis. Pleacă la următoarea deschidere a Bulwark.", + "unsent_many": "{count} mesaje așteaptă să fie trimise. Pleacă la următoarea deschidere a Bulwark.", + "quit": "Ieșire", + "leave_title": "Părăsiți pagina cu modificări nesalvate?", + "leave_detail": "Modificările făcute pe această pagină nu au fost salvate încă.", + "leave": "Părăsire", + "stay": "Rămâneți" + }, + "spellcheck": { + "offer": "Verificarea ortografică pentru {language} are nevoie de un dicționar. Bulwark îl poate descărca de la Google. Textul dvs. nu este trimis.", + "download": "Descărcare" + }, + "restore": { + "unavailable_title": "Această ciornă nu poate fi deschisă acum", + "unavailable_body": "Este în siguranță pe server, în Ciorne. Încercați din nou când revine conexiunea.", + "try_again": "Încercați din nou", + "close": "Închidere" } }, "desktop_sign_in": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 05ce8b7b8..ba407117a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6558,6 +6558,85 @@ "save_as": "Сохранить как…", "copy": "Копировать", "copy_failed": "Не удалось скопировать файл." + }, + "menu": { + "file": "Файл", + "edit": "Правка", + "view": "Вид", + "message": "Сообщение", + "go": "Переход", + "window": "Окно", + "help": "Справка", + "new_window": "Новое окно", + "print": "Печать…", + "save_pdf": "Сохранить как PDF…", + "switch_connection": "Сменить подключение…", + "close": "Закрыть", + "quit": "Выйти", + "undo": "Отменить", + "redo": "Повторить", + "cut": "Вырезать", + "copy": "Копировать", + "paste": "Вставить", + "select_all": "Выделить всё", + "search": "Поиск по почте", + "refresh": "Проверить новую почту", + "reading_pane": "Область чтения", + "pane_right": "Справа", + "pane_below": "Под списком", + "pane_off": "Выключена", + "zoom_in": "Увеличить", + "zoom_out": "Уменьшить", + "zoom_reset": "Реальный размер", + "full_screen": "Полноэкранный режим", + "open_in_window": "Открыть в новом окне", + "next_message": "Следующее сообщение", + "previous_message": "Предыдущее сообщение", + "next_unread": "Следующее непрочитанное сообщение", + "mail": "Почта", + "files": "Файлы", + "back": "Назад", + "forward": "Вперёд", + "minimize": "Свернуть", + "next_window": "Следующее окно", + "shortcuts": "Сочетания клавиш", + "docs": "Документация", + "settings": "Настройки…", + "about": "О программе Bulwark", + "from_plugins": "Ещё из плагинов…", + "pick_gone": "Это меню устарело. Откройте его снова и повторите попытку." + }, + "context": { + "add_to_dictionary": "Добавить «{word}» в словарь", + "no_suggestions": "Нет вариантов", + "open_link": "Открыть ссылку", + "copy_link": "Копировать ссылку", + "copy_address": "Копировать адрес", + "save_image": "Сохранить изображение…", + "copy_image": "Копировать изображение" + }, + "closing": { + "unsaved_title": "Не удалось сохранить черновик", + "unsaved_detail": "Если закрыть сейчас, всё написанное после последнего сохранения будет потеряно.", + "close_anyway": "Всё равно закрыть", + "unsent_title": "Часть писем ещё не отправлена", + "unsent_one": "Одно сообщение ждёт отправки. Оно уйдёт при следующем запуске Bulwark.", + "unsent_many": "Сообщений, ожидающих отправки: {count}. Они уйдут при следующем запуске Bulwark.", + "quit": "Выйти", + "leave_title": "Уйти с несохранёнными изменениями?", + "leave_detail": "Изменения на этой странице ещё не сохранены.", + "leave": "Уйти", + "stay": "Остаться" + }, + "spellcheck": { + "offer": "Для проверки орфографии на языке «{language}» нужен словарь. Bulwark может скачать его у Google. Ваш текст не отправляется.", + "download": "Скачать" + }, + "restore": { + "unavailable_title": "Этот черновик сейчас нельзя открыть", + "unavailable_body": "Он в сохранности на сервере, в папке «Черновики». Повторите попытку, когда появится соединение.", + "try_again": "Повторить", + "close": "Закрыть" } }, "desktop_sign_in": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 392915139..ff71bfbcb 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6558,6 +6558,85 @@ "save_as": "Uložiť ako…", "copy": "Kopírovať", "copy_failed": "Súbor sa nepodarilo skopírovať." + }, + "menu": { + "file": "Súbor", + "edit": "Upraviť", + "view": "Zobrazenie", + "message": "Správa", + "go": "Prejsť", + "window": "Okno", + "help": "Pomocník", + "new_window": "Nové okno", + "print": "Tlačiť…", + "save_pdf": "Uložiť ako PDF…", + "switch_connection": "Zmeniť pripojenie…", + "close": "Zavrieť", + "quit": "Ukončiť", + "undo": "Späť", + "redo": "Znova", + "cut": "Vystrihnúť", + "copy": "Kopírovať", + "paste": "Prilepiť", + "select_all": "Vybrať všetko", + "search": "Hľadať v pošte", + "refresh": "Skontrolovať novú poštu", + "reading_pane": "Panel čítania", + "pane_right": "Vpravo", + "pane_below": "Pod zoznamom", + "pane_off": "Vypnuté", + "zoom_in": "Priblížiť", + "zoom_out": "Oddialiť", + "zoom_reset": "Skutočná veľkosť", + "full_screen": "Celá obrazovka", + "open_in_window": "Otvoriť v novom okne", + "next_message": "Ďalšia správa", + "previous_message": "Predchádzajúca správa", + "next_unread": "Ďalšia neprečítaná správa", + "mail": "Pošta", + "files": "Súbory", + "back": "Späť", + "forward": "Dopredu", + "minimize": "Minimalizovať", + "next_window": "Ďalšie okno", + "shortcuts": "Klávesové skratky", + "docs": "Dokumentácia", + "settings": "Nastavenia…", + "about": "O aplikácii Bulwark", + "from_plugins": "Viac z doplnkov…", + "pick_gone": "Táto ponuka bola zastaraná. Otvorte ju znova a skúste to ešte raz." + }, + "context": { + "add_to_dictionary": "Pridať „{word}“ do slovníka", + "no_suggestions": "Žiadne návrhy", + "open_link": "Otvoriť odkaz", + "copy_link": "Kopírovať odkaz", + "copy_address": "Kopírovať adresu", + "save_image": "Uložiť obrázok…", + "copy_image": "Kopírovať obrázok" + }, + "closing": { + "unsaved_title": "Koncept sa nepodarilo uložiť", + "unsaved_detail": "Ak teraz zatvoríte okno, prídete o to, čo ste napísali od posledného uloženia.", + "close_anyway": "Napriek tomu zavrieť", + "unsent_title": "Niektorá pošta ešte nebola odoslaná", + "unsent_one": "Jedna správa čaká na odoslanie. Odíde pri ďalšom spustení Bulwark.", + "unsent_many": "Počet správ čakajúcich na odoslanie: {count}. Odídu pri ďalšom spustení Bulwark.", + "quit": "Ukončiť", + "leave_title": "Odísť s neuloženými zmenami?", + "leave_detail": "Zmeny na tejto stránke zatiaľ nie sú uložené.", + "leave": "Odísť", + "stay": "Zostať" + }, + "spellcheck": { + "offer": "Kontrola pravopisu pre jazyk {language} potrebuje slovník. Bulwark ho môže stiahnuť od spoločnosti Google. Váš text sa neodosiela.", + "download": "Stiahnuť" + }, + "restore": { + "unavailable_title": "Tento koncept sa teraz nedá otvoriť", + "unavailable_body": "Je v bezpečí na serveri v priečinku Koncepty. Skúste to znova, keď sa obnoví pripojenie.", + "try_again": "Skúsiť znova", + "close": "Zavrieť" } }, "desktop_sign_in": { diff --git a/locales/th/common.json b/locales/th/common.json index 0c34470ff..128d8564b 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6566,6 +6566,85 @@ "save_as": "บันทึกเป็น…", "copy": "คัดลอก", "copy_failed": "คัดลอกไฟล์ไม่ได้" + }, + "menu": { + "file": "ไฟล์", + "edit": "แก้ไข", + "view": "มุมมอง", + "message": "ข้อความ", + "go": "ไป", + "window": "หน้าต่าง", + "help": "ช่วยเหลือ", + "new_window": "หน้าต่างใหม่", + "print": "พิมพ์…", + "save_pdf": "บันทึกเป็น PDF…", + "switch_connection": "เปลี่ยนการเชื่อมต่อ…", + "close": "ปิด", + "quit": "ออก", + "undo": "เลิกทำ", + "redo": "ทำซ้ำ", + "cut": "ตัด", + "copy": "คัดลอก", + "paste": "วาง", + "select_all": "เลือกทั้งหมด", + "search": "ค้นหาอีเมล", + "refresh": "ตรวจสอบอีเมลใหม่", + "reading_pane": "บานหน้าต่างการอ่าน", + "pane_right": "ด้านขวา", + "pane_below": "ใต้รายการ", + "pane_off": "ปิด", + "zoom_in": "ขยาย", + "zoom_out": "ย่อ", + "zoom_reset": "ขนาดจริง", + "full_screen": "เต็มหน้าจอ", + "open_in_window": "เปิดในหน้าต่างใหม่", + "next_message": "ข้อความถัดไป", + "previous_message": "ข้อความก่อนหน้า", + "next_unread": "ข้อความที่ยังไม่ได้อ่านถัดไป", + "mail": "อีเมล", + "files": "ไฟล์", + "back": "ย้อนกลับ", + "forward": "ไปข้างหน้า", + "minimize": "ย่อหน้าต่าง", + "next_window": "หน้าต่างถัดไป", + "shortcuts": "แป้นพิมพ์ลัด", + "docs": "เอกสารประกอบ", + "settings": "การตั้งค่า…", + "about": "เกี่ยวกับ Bulwark", + "from_plugins": "เพิ่มเติมจากปลั๊กอิน…", + "pick_gone": "เมนูนี้เก่าแล้ว โปรดเปิดใหม่แล้วลองอีกครั้ง" + }, + "context": { + "add_to_dictionary": "เพิ่ม “{word}” ในพจนานุกรม", + "no_suggestions": "ไม่มีคำแนะนำ", + "open_link": "เปิดลิงก์", + "copy_link": "คัดลอกลิงก์", + "copy_address": "คัดลอกที่อยู่", + "save_image": "บันทึกรูปภาพ…", + "copy_image": "คัดลอกรูปภาพ" + }, + "closing": { + "unsaved_title": "บันทึกฉบับร่างไม่ได้", + "unsaved_detail": "หากปิดตอนนี้ สิ่งที่คุณเขียนหลังจากบันทึกครั้งล่าสุดจะหายไป", + "close_anyway": "ปิดต่อไป", + "unsent_title": "มีอีเมลที่ยังไม่ได้ส่ง", + "unsent_one": "มี 1 ข้อความรอส่ง จะส่งออกเมื่อคุณเปิด Bulwark ครั้งถัดไป", + "unsent_many": "มี {count} ข้อความรอส่ง จะส่งออกเมื่อคุณเปิด Bulwark ครั้งถัดไป", + "quit": "ออก", + "leave_title": "ออกโดยไม่บันทึกการเปลี่ยนแปลงหรือไม่", + "leave_detail": "การเปลี่ยนแปลงของคุณในหน้านี้ยังไม่ได้บันทึก", + "leave": "ออก", + "stay": "อยู่ต่อ" + }, + "spellcheck": { + "offer": "การตรวจตัวสะกดสำหรับ{language}ต้องมีพจนานุกรม Bulwark ดาวน์โหลดจาก Google ได้ ข้อความของคุณจะไม่ถูกส่งไป", + "download": "ดาวน์โหลด" + }, + "restore": { + "unavailable_title": "ตอนนี้ยังเปิดฉบับร่างนี้ไม่ได้", + "unavailable_body": "ฉบับร่างปลอดภัยอยู่บนเซิร์ฟเวอร์ ในโฟลเดอร์ฉบับร่าง ลองอีกครั้งเมื่อเชื่อมต่อได้", + "try_again": "ลองอีกครั้ง", + "close": "ปิด" } }, "desktop_sign_in": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 5e5365633..7e08bdca3 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6558,6 +6558,85 @@ "save_as": "Farklı kaydet…", "copy": "Kopyala", "copy_failed": "Dosya kopyalanamadı." + }, + "menu": { + "file": "Dosya", + "edit": "Düzen", + "view": "Görünüm", + "message": "İleti", + "go": "Git", + "window": "Pencere", + "help": "Yardım", + "new_window": "Yeni Pencere", + "print": "Yazdır…", + "save_pdf": "PDF Olarak Kaydet…", + "switch_connection": "Bağlantıyı Değiştir…", + "close": "Kapat", + "quit": "Çık", + "undo": "Geri Al", + "redo": "Yinele", + "cut": "Kes", + "copy": "Kopyala", + "paste": "Yapıştır", + "select_all": "Tümünü Seç", + "search": "E-postalarda Ara", + "refresh": "Yeni E-postaları Denetle", + "reading_pane": "Okuma Bölmesi", + "pane_right": "Sağda", + "pane_below": "Listenin Altında", + "pane_off": "Kapalı", + "zoom_in": "Yakınlaştır", + "zoom_out": "Uzaklaştır", + "zoom_reset": "Gerçek Boyut", + "full_screen": "Tam Ekran", + "open_in_window": "Yeni Pencerede Aç", + "next_message": "Sonraki İleti", + "previous_message": "Önceki İleti", + "next_unread": "Sonraki Okunmamış İleti", + "mail": "E-posta", + "files": "Dosyalar", + "back": "Geri", + "forward": "İleri", + "minimize": "Küçült", + "next_window": "Sonraki Pencere", + "shortcuts": "Klavye Kısayolları", + "docs": "Belgeler", + "settings": "Ayarlar…", + "about": "Bulwark Hakkında", + "from_plugins": "Eklentilerden Daha Fazlası…", + "pick_gone": "Bu menü eskimişti. Yeniden açın ve bir kez daha deneyin." + }, + "context": { + "add_to_dictionary": "“{word}” sözcüğünü sözlüğe ekle", + "no_suggestions": "Öneri yok", + "open_link": "Bağlantıyı Aç", + "copy_link": "Bağlantıyı Kopyala", + "copy_address": "Adresi Kopyala", + "save_image": "Resmi Kaydet…", + "copy_image": "Resmi Kopyala" + }, + "closing": { + "unsaved_title": "Bir taslak kaydedilemedi", + "unsaved_detail": "Şimdi kapatırsanız, son kayıttan beri yazdıklarınız kaybolur.", + "close_anyway": "Yine de kapat", + "unsent_title": "Bazı e-postalar henüz gönderilmedi", + "unsent_one": "Bir ileti gönderilmeyi bekliyor. Bulwark’ı bir sonraki açışınızda gönderilir.", + "unsent_many": "{count} ileti gönderilmeyi bekliyor. Bulwark’ı bir sonraki açışınızda gönderilir.", + "quit": "Çık", + "leave_title": "Kaydedilmemiş değişikliklerle ayrılınsın mı?", + "leave_detail": "Bu sayfada yaptığınız değişiklikler henüz kaydedilmedi.", + "leave": "Ayrıl", + "stay": "Kal" + }, + "spellcheck": { + "offer": "{language} yazım denetimi için bir sözlük gerekir. Bulwark bunu Google’dan indirebilir. Metniniz gönderilmez.", + "download": "İndir" + }, + "restore": { + "unavailable_title": "Bu taslak şu anda açılamıyor", + "unavailable_body": "Sunucuda, Taslaklar klasöründe güvende. Bağlantı geri geldiğinde yeniden deneyin.", + "try_again": "Yeniden dene", + "close": "Kapat" } }, "desktop_sign_in": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 9d6aaed35..e86001031 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6558,6 +6558,85 @@ "save_as": "Зберегти як…", "copy": "Копіювати", "copy_failed": "Не вдалося скопіювати файл." + }, + "menu": { + "file": "Файл", + "edit": "Редагування", + "view": "Вигляд", + "message": "Повідомлення", + "go": "Перехід", + "window": "Вікно", + "help": "Довідка", + "new_window": "Нове вікно", + "print": "Друк…", + "save_pdf": "Зберегти як PDF…", + "switch_connection": "Змінити підключення…", + "close": "Закрити", + "quit": "Вийти", + "undo": "Скасувати", + "redo": "Повторити", + "cut": "Вирізати", + "copy": "Копіювати", + "paste": "Вставити", + "select_all": "Вибрати все", + "search": "Пошук у пошті", + "refresh": "Перевірити нову пошту", + "reading_pane": "Область читання", + "pane_right": "Праворуч", + "pane_below": "Під списком", + "pane_off": "Вимкнено", + "zoom_in": "Збільшити", + "zoom_out": "Зменшити", + "zoom_reset": "Реальний розмір", + "full_screen": "На весь екран", + "open_in_window": "Відкрити в новому вікні", + "next_message": "Наступне повідомлення", + "previous_message": "Попереднє повідомлення", + "next_unread": "Наступне непрочитане повідомлення", + "mail": "Пошта", + "files": "Файли", + "back": "Назад", + "forward": "Вперед", + "minimize": "Згорнути", + "next_window": "Наступне вікно", + "shortcuts": "Комбінації клавіш", + "docs": "Документація", + "settings": "Налаштування…", + "about": "Про Bulwark", + "from_plugins": "Більше від плагінів…", + "pick_gone": "Це меню застаріло. Відкрийте його знову й спробуйте ще раз." + }, + "context": { + "add_to_dictionary": "Додати «{word}» до словника", + "no_suggestions": "Немає пропозицій", + "open_link": "Відкрити посилання", + "copy_link": "Копіювати посилання", + "copy_address": "Копіювати адресу", + "save_image": "Зберегти зображення…", + "copy_image": "Копіювати зображення" + }, + "closing": { + "unsaved_title": "Не вдалося зберегти чернетку", + "unsaved_detail": "Якщо закрити зараз, усе написане після останнього збереження буде втрачено.", + "close_anyway": "Закрити все одно", + "unsent_title": "Частину листів ще не надіслано", + "unsent_one": "Одне повідомлення чекає на надсилання. Воно піде під час наступного запуску Bulwark.", + "unsent_many": "Повідомлень, що чекають на надсилання: {count}. Вони підуть під час наступного запуску Bulwark.", + "quit": "Вийти", + "leave_title": "Піти з незбереженими змінами?", + "leave_detail": "Зміни на цій сторінці ще не збережено.", + "leave": "Піти", + "stay": "Залишитися" + }, + "spellcheck": { + "offer": "Для перевірки правопису мовою «{language}» потрібен словник. Bulwark може завантажити його від Google. Ваш текст не надсилається.", + "download": "Завантажити" + }, + "restore": { + "unavailable_title": "Цю чернетку зараз не можна відкрити", + "unavailable_body": "Вона в безпеці на сервері, у теці «Чернетки». Спробуйте ще раз, коли з’явиться з’єднання.", + "try_again": "Спробувати ще раз", + "close": "Закрити" } }, "desktop_sign_in": { diff --git a/locales/vi/common.json b/locales/vi/common.json index 2076cdb65..a53cd69bb 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6570,6 +6570,85 @@ "save_as": "Lưu thành…", "copy": "Sao chép", "copy_failed": "Không thể sao chép tệp." + }, + "menu": { + "file": "Tệp", + "edit": "Chỉnh sửa", + "view": "Xem", + "message": "Thư", + "go": "Đi tới", + "window": "Cửa sổ", + "help": "Trợ giúp", + "new_window": "Cửa sổ mới", + "print": "In…", + "save_pdf": "Lưu dưới dạng PDF…", + "switch_connection": "Đổi kết nối…", + "close": "Đóng", + "quit": "Thoát", + "undo": "Hoàn tác", + "redo": "Làm lại", + "cut": "Cắt", + "copy": "Sao chép", + "paste": "Dán", + "select_all": "Chọn tất cả", + "search": "Tìm trong thư", + "refresh": "Kiểm tra thư mới", + "reading_pane": "Ô đọc thư", + "pane_right": "Bên phải", + "pane_below": "Bên dưới danh sách", + "pane_off": "Tắt", + "zoom_in": "Phóng to", + "zoom_out": "Thu nhỏ", + "zoom_reset": "Kích thước thực", + "full_screen": "Toàn màn hình", + "open_in_window": "Mở trong cửa sổ mới", + "next_message": "Thư tiếp theo", + "previous_message": "Thư trước", + "next_unread": "Thư chưa đọc tiếp theo", + "mail": "Thư", + "files": "Tệp", + "back": "Quay lại", + "forward": "Tiến tới", + "minimize": "Thu nhỏ cửa sổ", + "next_window": "Cửa sổ tiếp theo", + "shortcuts": "Phím tắt", + "docs": "Tài liệu", + "settings": "Cài đặt…", + "about": "Giới thiệu về Bulwark", + "from_plugins": "Thêm từ các plugin…", + "pick_gone": "Menu này đã cũ. Hãy mở lại và thử một lần nữa." + }, + "context": { + "add_to_dictionary": "Thêm “{word}” vào từ điển", + "no_suggestions": "Không có gợi ý", + "open_link": "Mở liên kết", + "copy_link": "Sao chép liên kết", + "copy_address": "Sao chép địa chỉ", + "save_image": "Lưu hình ảnh…", + "copy_image": "Sao chép hình ảnh" + }, + "closing": { + "unsaved_title": "Không thể lưu một bản nháp", + "unsaved_detail": "Nếu bạn đóng bây giờ, nội dung bạn đã viết kể từ lần lưu gần nhất sẽ bị mất.", + "close_anyway": "Vẫn đóng", + "unsent_title": "Một số thư chưa được gửi", + "unsent_one": "Có 1 thư đang chờ gửi. Thư sẽ được gửi vào lần tới bạn mở Bulwark.", + "unsent_many": "Có {count} thư đang chờ gửi. Các thư sẽ được gửi vào lần tới bạn mở Bulwark.", + "quit": "Thoát", + "leave_title": "Rời đi khi còn thay đổi chưa lưu?", + "leave_detail": "Các thay đổi của bạn trên trang này chưa được lưu.", + "leave": "Rời đi", + "stay": "Ở lại" + }, + "spellcheck": { + "offer": "Kiểm tra chính tả cho {language} cần một từ điển. Bulwark có thể tải từ Google. Văn bản của bạn không bị gửi đi.", + "download": "Tải xuống" + }, + "restore": { + "unavailable_title": "Hiện không thể mở bản nháp này", + "unavailable_body": "Bản nháp vẫn an toàn trên máy chủ, trong mục Thư nháp. Hãy thử lại khi có kết nối.", + "try_again": "Thử lại", + "close": "Đóng" } }, "desktop_sign_in": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 53eefab4f..019716c37 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6558,6 +6558,85 @@ "save_as": "另存新檔…", "copy": "複製", "copy_failed": "無法複製檔案。" + }, + "menu": { + "file": "檔案", + "edit": "編輯", + "view": "檢視", + "message": "郵件", + "go": "前往", + "window": "視窗", + "help": "說明", + "new_window": "新增視窗", + "print": "列印…", + "save_pdf": "儲存為 PDF…", + "switch_connection": "變更連線…", + "close": "關閉", + "quit": "結束", + "undo": "還原", + "redo": "重做", + "cut": "剪下", + "copy": "拷貝", + "paste": "貼上", + "select_all": "全選", + "search": "搜尋郵件", + "refresh": "檢查新郵件", + "reading_pane": "閱讀窗格", + "pane_right": "在右側", + "pane_below": "在清單下方", + "pane_off": "關閉", + "zoom_in": "放大", + "zoom_out": "縮小", + "zoom_reset": "實際大小", + "full_screen": "全螢幕", + "open_in_window": "在新視窗中開啟", + "next_message": "下一封郵件", + "previous_message": "上一封郵件", + "next_unread": "下一封未讀郵件", + "mail": "郵件", + "files": "檔案", + "back": "返回", + "forward": "下一頁", + "minimize": "最小化", + "next_window": "下一個視窗", + "shortcuts": "鍵盤快速鍵", + "docs": "說明文件", + "settings": "設定…", + "about": "關於 Bulwark", + "from_plugins": "更多外掛程式選項…", + "pick_gone": "此選單已過期。請重新開啟後再試一次。" + }, + "context": { + "add_to_dictionary": "將「{word}」加入字典", + "no_suggestions": "沒有建議", + "open_link": "開啟連結", + "copy_link": "拷貝連結", + "copy_address": "拷貝位址", + "save_image": "儲存圖片…", + "copy_image": "拷貝圖片" + }, + "closing": { + "unsaved_title": "草稿無法儲存", + "unsaved_detail": "如果現在關閉,自上次儲存以來所寫的內容將會遺失。", + "close_anyway": "仍要關閉", + "unsent_title": "有郵件尚未寄出", + "unsent_one": "有 1 封郵件正在等待寄出,將在您下次開啟 Bulwark 時寄出。", + "unsent_many": "有 {count} 封郵件正在等待寄出,將在您下次開啟 Bulwark 時寄出。", + "quit": "結束", + "leave_title": "有未儲存的變更,確定要離開嗎?", + "leave_detail": "您在此頁面所做的變更尚未儲存。", + "leave": "離開", + "stay": "留下" + }, + "spellcheck": { + "offer": "{language}拼字檢查需要字典。Bulwark 可以從 Google 下載。您的文字不會被傳送。", + "download": "下載" + }, + "restore": { + "unavailable_title": "目前無法開啟此草稿", + "unavailable_body": "草稿安全地保存在伺服器的「草稿」中。連線恢復後請再試一次。", + "try_again": "重試", + "close": "關閉" } }, "desktop_sign_in": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 770f679eb..9ad6edd12 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6558,6 +6558,85 @@ "save_as": "另存为…", "copy": "复制", "copy_failed": "无法复制文件。" + }, + "menu": { + "file": "文件", + "edit": "编辑", + "view": "视图", + "message": "邮件", + "go": "前往", + "window": "窗口", + "help": "帮助", + "new_window": "新建窗口", + "print": "打印…", + "save_pdf": "另存为 PDF…", + "switch_connection": "更改连接…", + "close": "关闭", + "quit": "退出", + "undo": "撤销", + "redo": "重做", + "cut": "剪切", + "copy": "复制", + "paste": "粘贴", + "select_all": "全选", + "search": "搜索邮件", + "refresh": "检查新邮件", + "reading_pane": "阅读窗格", + "pane_right": "在右侧", + "pane_below": "在列表下方", + "pane_off": "关闭", + "zoom_in": "放大", + "zoom_out": "缩小", + "zoom_reset": "实际大小", + "full_screen": "全屏", + "open_in_window": "在新窗口中打开", + "next_message": "下一封邮件", + "previous_message": "上一封邮件", + "next_unread": "下一封未读邮件", + "mail": "邮件", + "files": "文件", + "back": "后退", + "forward": "前进", + "minimize": "最小化", + "next_window": "下一个窗口", + "shortcuts": "键盘快捷键", + "docs": "文档", + "settings": "设置…", + "about": "关于 Bulwark", + "from_plugins": "更多插件选项…", + "pick_gone": "该菜单已过期。请重新打开后再试一次。" + }, + "context": { + "add_to_dictionary": "将“{word}”添加到词典", + "no_suggestions": "无建议", + "open_link": "打开链接", + "copy_link": "复制链接", + "copy_address": "复制地址", + "save_image": "保存图片…", + "copy_image": "复制图片" + }, + "closing": { + "unsaved_title": "草稿无法保存", + "unsaved_detail": "如果现在关闭,自上次保存以来所写的内容将会丢失。", + "close_anyway": "仍然关闭", + "unsent_title": "有邮件尚未发送", + "unsent_one": "有 1 封邮件正在等待发送,将在您下次打开 Bulwark 时发出。", + "unsent_many": "有 {count} 封邮件正在等待发送,将在您下次打开 Bulwark 时发出。", + "quit": "退出", + "leave_title": "有未保存的更改,确定离开吗?", + "leave_detail": "您在此页面所做的更改尚未保存。", + "leave": "离开", + "stay": "留下" + }, + "spellcheck": { + "offer": "{language}拼写检查需要词典。Bulwark 可以从 Google 下载。您的文字不会被发送。", + "download": "下载" + }, + "restore": { + "unavailable_title": "目前无法打开此草稿", + "unavailable_body": "草稿安全地保存在服务器的“草稿”中。连接恢复后请重试。", + "try_again": "重试", + "close": "关闭" } }, "desktop_sign_in": { From 0872e094a2befe07b929cc27e9fb78c515a495e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:57:03 +0100 Subject: [PATCH 15/45] test: check that wiping one of several accounts clears what the desktop app shows --- lib/desktop/__tests__/os-store.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts index e09aee90d..5b793cd9f 100644 --- a/lib/desktop/__tests__/os-store.test.ts +++ b/lib/desktop/__tests__/os-store.test.ts @@ -55,6 +55,9 @@ describe('the store and the OS integration', () => { 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(); window.bulwarkDesktop = undefined; From 8c0a45b865a4628697b660f9fe05ba7d841e393f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:57:28 +0100 Subject: [PATCH 16/45] test: leave no enabled accounts behind in the store and OS integration test --- lib/desktop/__tests__/os-store.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts index 5b793cd9f..925e4d024 100644 --- a/lib/desktop/__tests__/os-store.test.ts +++ b/lib/desktop/__tests__/os-store.test.ts @@ -60,6 +60,7 @@ describe('the store and the OS integration', () => { useOfflineStore.setState({ enabled: ['alice@one', 'bob@two'] }); await wipeAccount('alice@one').catch(() => {}); expect(clearSurfaces).toHaveBeenCalled(); + useOfflineStore.setState({ enabled: [] }); window.bulwarkDesktop = undefined; }); From fb3c82eed52b1a691d20b238096665ad6547bd6a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:08:37 +0100 Subject: [PATCH 17/45] feat: lock the app with a PIN, a passkey or Windows Hello, with mail out of memory and the local store closed The lock is off by default, and an OS policy can require it and set the timeout. It locks after a time without use counted across all windows and tabs, on OS screen lock in the desktop app, and from Lock now. Locking saves open composers, stops the outbox at its next message, closes the store and stops its worker with every key, then reloads the page, so nothing of the old page stays in reach. The new page holds the app back at a Suspense boundary until someone unlocks. Pop-outs get no session while locked, push notifications name nobody, and subjects leave plain storage. One random lock key wraps each account's key as a layer. The PIN wraps the lock key with PBKDF2 at 600,000 iterations, with a wait after wrong tries that a reload doesn't reset. A passkey with PRF and the desktop's OS storage wrap it too. Details and limits are in the decisions file. --- app/(main)/[locale]/layout.tsx | 3 + app/(main)/layout.tsx | 3 + app/app-lock.css | 9 + .../app-lock/__tests__/lock-screen.test.tsx | 141 ++++ components/app-lock/app-lock-gate.tsx | 61 ++ components/app-lock/lock-screen.tsx | 265 +++++++ components/app-lock/pin-fields.tsx | 77 ++ components/contacts/contacts-app.tsx | 6 +- components/email/email-composer.tsx | 14 +- .../popout/__tests__/popout-host.test.tsx | 25 + components/popout/popout-host.tsx | 5 +- components/settings/app-lock-settings.tsx | 204 +++++ components/settings/general-settings.tsx | 3 + integration/tests/24-do-app-lock.spec.ts | 411 ++++++++++ lib/__tests__/sw-push-app-lock.test.ts | 66 ++ lib/app-lock/__tests__/controller.test.ts | 471 +++++++++++ lib/app-lock/__tests__/idle.test.ts | 89 +++ lib/app-lock/__tests__/vault.test.ts | 158 ++++ lib/app-lock/controller.ts | 730 ++++++++++++++++++ lib/app-lock/crypto.ts | 115 +++ lib/app-lock/idle.ts | 85 ++ lib/app-lock/passkey.ts | 81 ++ lib/app-lock/policy.ts | 64 ++ lib/app-lock/scrub.ts | 44 ++ lib/app-lock/sign-out.ts | 15 + lib/app-lock/state.ts | 103 +++ lib/app-lock/vault.ts | 262 +++++++ lib/compose-flush-registry.ts | 27 + lib/desktop/app-lock-state.ts | 19 + lib/desktop/types.ts | 30 + lib/offline/__tests__/outbox.test.ts | 40 + lib/offline/flush.ts | 3 + lib/offline/offline-mode.ts | 3 + lib/popout/channel.ts | 8 +- lib/popout/sealed.ts | 13 +- lib/settings-search.ts | 4 +- lib/sign-out-cleanup.ts | 3 + lib/store/client/api.ts | 76 +- locales/ar/common.json | 65 ++ locales/ca/common.json | 65 ++ locales/cs/common.json | 65 ++ locales/da/common.json | 65 ++ locales/de/common.json | 65 ++ locales/en/common.json | 65 ++ locales/es/common.json | 65 ++ locales/et/common.json | 65 ++ locales/fa/common.json | 65 ++ locales/fr/common.json | 65 ++ locales/he/common.json | 65 ++ locales/hu/common.json | 65 ++ locales/it/common.json | 65 ++ locales/ja/common.json | 65 ++ locales/ko/common.json | 65 ++ locales/lv/common.json | 65 ++ locales/mn/common.json | 65 ++ locales/nb/common.json | 65 ++ locales/nl/common.json | 65 ++ locales/pl/common.json | 65 ++ locales/pt/common.json | 65 ++ locales/ro/common.json | 65 ++ locales/ru/common.json | 65 ++ locales/sk/common.json | 65 ++ locales/th/common.json | 65 ++ locales/tr/common.json | 65 ++ locales/uk/common.json | 65 ++ locales/vi/common.json | 65 ++ locales/zh-TW/common.json | 65 ++ locales/zh/common.json | 65 ++ public/sw.js | 53 +- .../__tests__/auth-store-keep-local.test.ts | 13 + stores/auth-store.ts | 7 +- stores/email-store.ts | 3 + 72 files changed, 5748 insertions(+), 14 deletions(-) create mode 100644 app/app-lock.css create mode 100644 components/app-lock/__tests__/lock-screen.test.tsx create mode 100644 components/app-lock/app-lock-gate.tsx create mode 100644 components/app-lock/lock-screen.tsx create mode 100644 components/app-lock/pin-fields.tsx create mode 100644 components/settings/app-lock-settings.tsx create mode 100644 integration/tests/24-do-app-lock.spec.ts create mode 100644 lib/__tests__/sw-push-app-lock.test.ts create mode 100644 lib/app-lock/__tests__/controller.test.ts create mode 100644 lib/app-lock/__tests__/idle.test.ts create mode 100644 lib/app-lock/__tests__/vault.test.ts create mode 100644 lib/app-lock/controller.ts create mode 100644 lib/app-lock/crypto.ts create mode 100644 lib/app-lock/idle.ts create mode 100644 lib/app-lock/passkey.ts create mode 100644 lib/app-lock/policy.ts create mode 100644 lib/app-lock/scrub.ts create mode 100644 lib/app-lock/sign-out.ts create mode 100644 lib/app-lock/state.ts create mode 100644 lib/app-lock/vault.ts create mode 100644 lib/desktop/app-lock-state.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 762f30ab9..68886353b 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation"; +import { AppLockGate } from "@/components/app-lock/app-lock-gate"; import { IntlProvider } from "@/components/providers/intl-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider"; @@ -76,6 +77,7 @@ export default async function LocaleLayout({ return ( + @@ -130,6 +132,7 @@ export default async function LocaleLayout({ + ); } diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index e2704bcc8..429353ae5 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -25,6 +25,8 @@ import "../globals.css"; import "../print.css"; import "../desktop.css"; import { desktopBootScript } from "@/lib/desktop/boot"; +import { appLockBootScript } from "@/lib/app-lock/state"; +import "../app-lock.css"; // This layout renders and sits ABOVE the [locale] segment, so // next-intl's getLocale() returns the default locale here - emitting @@ -312,6 +314,7 @@ export default async function RootLayout({ __html: ` (function() {${IS_LITE ? liteLocaleBootstrap() : ""} ${desktopBootScript()} + ${IS_LITE ? "" : appLockBootScript()} try { const stored = localStorage.getItem('theme-storage'); const theme = stored ? JSON.parse(stored).state.theme : 'system'; diff --git a/app/app-lock.css b/app/app-lock.css new file mode 100644 index 000000000..b62a0201c --- /dev/null +++ b/app/app-lock.css @@ -0,0 +1,9 @@ +/* + * While the app lock is on and it isn't known yet to be unlocked, nothing under the + * lock screen shows, from the first frame. The inline script in app/(main)/layout.tsx + * sets the attribute before the first paint, and lib/app-lock/controller.ts takes it away. + * The window's drag strip stays: it shows the app's name and nothing else. + */ +html[data-app-locked] body > *:not([data-app-lock-overlay]):not(.window-title-bar) { + visibility: hidden !important; +} diff --git a/components/app-lock/__tests__/lock-screen.test.tsx b/components/app-lock/__tests__/lock-screen.test.tsx new file mode 100644 index 000000000..175e0994b --- /dev/null +++ b/components/app-lock/__tests__/lock-screen.test.tsx @@ -0,0 +1,141 @@ +import 'fake-indexeddb/auto'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AppLockSettings } from '@/components/settings/app-lock-settings'; +import { LockScreen } from '../lock-screen'; +import { createVault, deleteVault, FREE_FAILURES } from '@/lib/app-lock/vault'; +import { writeAppLockFile } from '@/lib/app-lock/state'; + +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) })); + +const unlockWithPin = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/app-lock/controller', async (original) => ({ + ...(await original()), + unlockWithPin, +})); + +function setPolicy(required: boolean, minutes: number | null, locked: boolean): void { + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + policy: { + get: () => ({ + AppLockRequired: { value: required, locked: required, source: required ? 'forced-machine' : 'default' }, + AppLockTimeoutMinutes: { value: minutes, locked, source: locked ? 'forced-machine' : 'default' }, + }), + onChange: () => () => {}, + }, + }; +} + +beforeEach(async () => { + await deleteVault(); + localStorage.clear(); + unlockWithPin.mockReset(); +}); + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +function typeAndSubmit(pin: string): void { + fireEvent.change(screen.getByTestId('app-lock-pin'), { target: { value: pin } }); + fireEvent.submit(screen.getByTestId('app-lock-form')); +} + +describe('the lock screen', () => { + it('passes the PIN on and shows nothing but the lock', async () => { + (await createVault('correct-pin')).fill(0); + unlockWithPin.mockResolvedValue(undefined); + render(); + typeAndSubmit('correct-pin'); + await waitFor(() => expect(unlockWithPin).toHaveBeenCalledWith('correct-pin')); + expect(screen.getByRole('dialog')).toHaveAttribute('data-app-lock-overlay'); + }); + + it('says a wrong PIN was wrong, and empties the field', async () => { + (await createVault('correct-pin')).fill(0); + unlockWithPin.mockRejectedValue(Object.assign(new Error('x'), { name: 'WrongPinError' })); + render(); + typeAndSubmit('nope-nope'); + await waitFor(() => expect(screen.getByTestId('app-lock-message')).toHaveTextContent('wrong_pin')); + expect(screen.getByTestId('app-lock-pin')).toHaveValue(''); + }); + + it('comes up blocked after a reload, from what is on disk', async () => { + (await createVault('correct-pin')).fill(0); + const { openVaultWithPin } = await import('@/lib/app-lock/vault'); + for (let i = 0; i <= FREE_FAILURES; i += 1) await openVaultWithPin('wrong-pin').catch(() => {}); + await act(async () => { + render(); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + await waitFor(() => expect(screen.getByTestId('app-lock-pin')).toBeDisabled()); + expect(screen.getByTestId('app-lock-message').textContent).toMatch(/blocked_/); + expect(screen.getByTestId('app-lock-unlock')).toBeDisabled(); + }); + + it('offers no passkey or OS button when none is set up, and always the way out', async () => { + (await createVault('correct-pin')).fill(0); + render(); + await waitFor(() => expect(screen.getByTestId('app-lock-forgot')).toBeInTheDocument()); + expect(screen.queryByTestId('app-lock-passkey')).toBeNull(); + expect(screen.queryByTestId('app-lock-os')).toBeNull(); + }); + + it('says it\'s saving rather than ask for anything while it locks', () => { + render(); + expect(screen.queryByTestId('app-lock-pin')).toBeNull(); + expect(screen.getByRole('dialog')).toHaveTextContent('locking'); + }); + + it('asks for a PIN when an organisation requires it, and only a good one', () => { + render(); + expect(screen.getByRole('dialog')).toHaveTextContent('setup_required_title'); + const save = screen.getByRole('button', { name: 'save' }); + expect(save).toBeDisabled(); + fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '12345' } }); + fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '12345' } }); + expect(save).toBeDisabled(); + fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '123456' } }); + expect(save).toBeDisabled(); + fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '123456' } }); + expect(save).toBeEnabled(); + }); +}); + +describe('the settings', () => { + it('are off by default, with nothing else shown', () => { + render(); + expect(screen.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false'); + expect(screen.queryByTestId('app-lock-now')).toBeNull(); + expect(screen.queryByTestId('app-lock-timeout')).toBeNull(); + }); + + it('show the switch as set by the organisation when a policy requires it', () => { + setPolicy(true, null, false); + writeAppLockFile({ on: true, locked: false }); + const { container } = render(); + const toggle = screen.getByTestId('app-lock-switch'); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(toggle.closest('[inert]')).not.toBeNull(); + expect(container.textContent).toContain('org_locked'); + }); + + it('show a forced timeout as set by the organisation, with its value', () => { + setPolicy(false, 3, true); + writeAppLockFile({ on: true, locked: false, timeoutMinutes: 60 }); + render(); + const select = screen.getByTestId('app-lock-timeout'); + expect(select).toHaveValue('3'); + expect(select.closest('[inert]')).not.toBeNull(); + }); + + it('leave the timeout to the user otherwise', () => { + writeAppLockFile({ on: true, locked: false, timeoutMinutes: 30 }); + render(); + const select = screen.getByTestId('app-lock-timeout'); + expect(select).toHaveValue('30'); + expect(select.closest('[inert]')).toBeNull(); + }); +}); diff --git a/components/app-lock/app-lock-gate.tsx b/components/app-lock/app-lock-gate.tsx new file mode 100644 index 000000000..13d2c9559 --- /dev/null +++ b/components/app-lock/app-lock-gate.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Suspense, use, useEffect, useState, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; +import { LockScreen } from "./lock-screen"; +import { appLockHold, bootAppLock, getAppLockPhase, recheckAppLockSetup, startAppLock, subscribeAppLockPhase, takeDraftsSavedNotice, type AppLockPhase } from "@/lib/app-lock/controller"; +import { IS_LITE } from "@/lib/lite"; +import { useAccountStore } from "@/stores/account-store"; +import { toast } from "@/stores/toast-store"; +import { useTranslations } from "next-intl"; + +const serverPhase = (): AppLockPhase => "open"; + +/** + * Suspends the app below while the lock holds. In the first render of a page that is + * locked, that keeps the server's markup inert: nothing below hydrates, so no effect runs, + * no store fills and no plugin frame starts, until someone unlocks in this same document. + * The server's markup is the empty skeleton, never mail. + */ +function Hold() { + if (typeof window === "undefined") return null; + bootAppLock(); + const hold = appLockHold(); + if (hold) use(hold); + return null; +} + +/** Above everything that touches mail, and in every window: main, pop-outs, composers. */ +export function AppLockGate({ children }: { children: React.ReactNode }) { + const phase = useSyncExternalStore(subscribeAppLockPhase, getAppLockPhase, serverPhase); + const t = useTranslations("appLock"); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + return startAppLock(); + }, []); + + // Signing in while an organisation requires the lock: it's due now. + useEffect(() => { + if (IS_LITE) return; + recheckAppLockSetup(); + return useAccountStore.subscribe(recheckAppLockSetup); + }, []); + + useEffect(() => { + if (phase === "open" && takeDraftsSavedNotice()) toast.info(t("draft_saved_notice")); + }, [phase, t]); + + if (IS_LITE) return <>{children}; + + return ( + <> + + + {phase === "locked-stay" ? null : children} + + {mounted && phase !== "open" && phase !== "checking" && createPortal(, document.body)} + + ); +} diff --git a/components/app-lock/lock-screen.tsx b/components/app-lock/lock-screen.tsx new file mode 100644 index 000000000..2dc827000 --- /dev/null +++ b/components/app-lock/lock-screen.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from "react"; +import { useTranslations } from "next-intl"; +import { Lock } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Input } from "@/components/ui/input"; +import { useConfig } from "@/hooks/use-config"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { NewPinFields, newPinReady } from "./pin-fields"; +import { enableAppLock, forgetEverything, unlockWithOs, unlockWithPasskey, unlockWithPin, type AppLockPhase } from "@/lib/app-lock/controller"; +import { passkeysPossible } from "@/lib/app-lock/passkey"; +import { APP_LOCK_CHANNEL, postAppLockMessage, type AppLockMessage } from "@/lib/app-lock/state"; +import { pinBlockedUntil, readVault } from "@/lib/app-lock/vault"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopOsUnlockKind } from "@/lib/desktop/types"; +import { isPopoutWindow } from "@/lib/popout/window-role"; + +/** How long a pop-out waits to hear from a main window before it asks for the PIN itself. */ +const MAIN_ANSWER_MS = 500; + +function secondsLeft(until: number): number { + return Math.max(0, Math.ceil((until - Date.now()) / 1000)); +} + +function useBlockedUntil(): [number, (until: number) => void] { + const [until, setUntil] = useState(0); + const [, tick] = useState(0); + useEffect(() => { + void pinBlockedUntil().then(setUntil).catch(() => {}); + }, []); + useEffect(() => { + if (until <= Date.now()) return; + const timer = setInterval(() => tick((n) => n + 1), 1000); + return () => clearInterval(timer); + }, [until]); + return [until > Date.now() ? until : 0, setUntil]; +} + +/** + * Whether a main window is there. The tab that unlocks keeps the key for as long as it lives, + * and a pop-out is about to close: so a pop-out sends people to the main window. + */ +function useMainWindowThere(): boolean { + const [there, setThere] = useState(false); + useEffect(() => { + if (!isPopoutWindow() || typeof BroadcastChannel === "undefined") return; + const channel = new BroadcastChannel(APP_LOCK_CHANNEL); + channel.onmessage = (event: MessageEvent) => { + if (event.data.type === "main-pong") setThere(true); + }; + postAppLockMessage({ type: "main-ping" }); + const timer = setTimeout(() => postAppLockMessage({ type: "main-ping" }), MAIN_ANSWER_MS); + return () => { + clearTimeout(timer); + channel.close(); + }; + }, []); + return there; +} + +function Frame({ title, body, children }: { title: string; body?: string; children: React.ReactNode }) { + const titleId = useId(); + return ( +
+
+
+ + +

{title}

+ {body &&

{body}

} +
+ {children} +
+
+ ); +} + +function SetupForm() { + const t = useTranslations("appLock"); + const { appName } = useConfig(); + const [pin, setPin] = useState(""); + const [repeat, setRepeat] = useState(""); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!newPinReady(pin, repeat) || busy) return; + setBusy(true); + setFailed(false); + try { + await enableAppLock(pin); + } catch { + setFailed(true); + setBusy(false); + } + }; + return ( + +
+ + {failed &&

{t("save_failed")}

} + + + + ); +} + +function UnlockForm() { + const t = useTranslations("appLock"); + const { appName } = useConfig(); + const { dialogProps, confirm } = useConfirmDialog(); + const [pin, setPin] = useState(""); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + const [blockedUntil, setBlockedUntil] = useBlockedUntil(); + const [hasPasskey, setHasPasskey] = useState(false); + const [osKind, setOsKind] = useState(null); + const mainThere = useMainWindowThere(); + const inputRef = useRef(null); + const pinId = useId(); + + useEffect(() => { + void readVault().then((vault) => setHasPasskey(Boolean(vault?.prf) && passkeysPossible())).catch(() => {}); + void getDesktopBridge()?.appLock?.status().then((status) => setOsKind(status.osEnrolled ? status.os : null)).catch(() => {}); + }, []); + + const blocked = blockedUntil > 0; + const left = secondsLeft(blockedUntil); + + const submit = useCallback(async (event: FormEvent) => { + event.preventDefault(); + if (!pin || busy || blocked) return; + setBusy(true); + setMessage(null); + try { + await unlockWithPin(pin); + } catch (error) { + const name = (error as Error)?.name; + setBusy(false); + setPin(""); + inputRef.current?.focus(); + if (name === "PinBlockedError") setBlockedUntil((error as Error & { until: number }).until); + else if (name === "WrongPinError") { + setMessage(t("wrong_pin")); + void pinBlockedUntil().then(setBlockedUntil).catch(() => {}); + } else setMessage(t("unlock_failed")); + } + }, [blocked, busy, pin, setBlockedUntil, t]); + + const withOs = async () => { + setBusy(true); + setMessage(null); + try { + if (await unlockWithOs()) return; + } catch { + // The PIN below still works. + } + setBusy(false); + setMessage(t("os_failed")); + }; + + const withPasskey = async () => { + setBusy(true); + setMessage(null); + try { + await unlockWithPasskey(); + } catch { + setBusy(false); + setMessage(t("passkey_failed")); + } + }; + + const forgot = async () => { + const ok = await confirm({ + title: t("forgot_title"), + message: t("forgot_body"), + confirmText: t("forgot_confirm"), + variant: "destructive", + }); + if (ok) await forgetEverything(); + }; + + if (mainThere) { + return ( + + + + ); + } + + return ( + +
+ + setPin(event.target.value)} + placeholder={t("pin_label")} + disabled={blocked} + /> +
+ {blocked + ? (left >= 60 ? t("blocked_minutes", { count: Math.ceil(left / 60) }) : t("blocked_seconds", { count: left })) + : message} +
+ + {osKind && ( + + )} + {hasPasskey && ( + + )} + +
+ + + ); +} + +/** What covers a window while the app is locked. Reads nothing but the lock's own records. */ +export function LockScreen({ phase }: { phase: AppLockPhase }) { + const t = useTranslations("appLock"); + const { appName } = useConfig(); + if (phase === "setup") return ; + if (phase === "locking") { + return ( + + + + ); + } + return ; +} diff --git a/components/app-lock/pin-fields.tsx b/components/app-lock/pin-fields.tsx new file mode 100644 index 000000000..21470aeaf --- /dev/null +++ b/components/app-lock/pin-fields.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useId } from "react"; +import { useTranslations } from "next-intl"; +import { Input } from "@/components/ui/input"; +import { pinLongEnough } from "@/lib/app-lock/crypto"; + +/** The new PIN, twice. Says what's wrong only once there's something to say. */ +export function NewPinFields({ pin, repeat, onPin, onRepeat }: { + pin: string; + repeat: string; + onPin: (value: string) => void; + onRepeat: (value: string) => void; +}) { + const t = useTranslations("appLock"); + const pinId = useId(); + const repeatId = useId(); + const hintId = useId(); + const tooShort = pin.length > 0 && !pinLongEnough(pin); + const mismatch = repeat.length > 0 && repeat !== pin; + return ( +
+
+ + onPin(event.target.value)} + aria-describedby={hintId} + aria-invalid={tooShort || undefined} + /> +

{t("pin_hint")}

+
+
+ + onRepeat(event.target.value)} + aria-invalid={mismatch || undefined} + /> + {mismatch &&

{t("pin_mismatch")}

} +
+
+ ); +} + +export function newPinReady(pin: string, repeat: string): boolean { + return pinLongEnough(pin) && pin === repeat; +} + +/** A PIN already set, asked again before something that needs it. */ +export function CurrentPinField({ value, onChange, error }: { value: string; onChange: (value: string) => void; error?: string | null }) { + const t = useTranslations("appLock"); + const id = useId(); + return ( +
+ + onChange(event.target.value)} + aria-invalid={error ? true : undefined} + /> + {error &&

{error}

} +
+ ); +} diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index fcf3b0afd..a28c84ea1 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -1,5 +1,6 @@ "use client"; +import { isAppLockedNow } from "@/lib/app-lock/state"; import { AppMain, SkipLink } from "@/components/layout/app-landmarks"; import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useLocale, useTranslations } from "next-intl"; @@ -231,7 +232,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = // Leaving the page or going Back asks too. useEffect(() => { if (!editorDirty) return; - const onBeforeUnload = (e: BeforeUnloadEvent) => e.preventDefault(); + const onBeforeUnload = (e: BeforeUnloadEvent) => { + // The app lock asks nobody: the page is going to the lock screen. + if (!isAppLockedNow()) e.preventDefault(); + }; window.addEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload); }, [editorDirty]); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 67be255df..db59d734e 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -168,7 +168,8 @@ import type { Editor } from "@tiptap/react"; import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; -import { registerComposeFlush } from "@/lib/compose-flush-registry"; +import { isAppLockedNow } from "@/lib/app-lock/state"; +import { registerComposeFlush, registerComposeSnapshot } from "@/lib/compose-flush-registry"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; @@ -1958,6 +1959,8 @@ export function EmailComposer({ ownWindowRef.current = ownWindow; useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { + // The lock saved what it could and is on its way to the lock screen: no prompt, and no copy of the text. + if (isAppLockedNow()) return; const own = ownWindowRef.current; if (!own) { if (isDirtyRef.current) { @@ -3142,6 +3145,15 @@ export function EmailComposer({ if (saveFailedRef.current) throw new Error('The draft was not saved'); }), []); + // Where this composer's text is, for the app lock. + useEffect(() => registerComposeSnapshot({ + ownWindow: !!ownWindowRef.current, + state: () => (sentRef.current || uneditedRef.current ? null : snapshotStateRef.current()), + draft: () => (draftIdRef.current && !sentRef.current + ? { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current } + : null), + }), []); + // The draft as plugins see it: onDraftChange, the frames above and below // the body, and clicks on plugin entries in the Send menu. const draftSnapshot = () => ({ diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index 4defd25c8..239f3045c 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -1,6 +1,7 @@ import { act, cleanup, render } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; +import { setLockedHere } from '@/lib/app-lock/state'; import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { takeRescuedDraft } from '@/lib/popout/rescued-drafts'; import { createSessionOpener } from '@/lib/popout/sealed'; @@ -121,6 +122,30 @@ describe('PopoutHost', () => { expect(JSON.stringify(heard)).not.toContain('Bearer main'); }); + it('gives no credentials while the app is locked, also to a pop-out that never heard of the lock', async () => { + render(); + await settle(); + setLockedHere(true); + const channel = openPopoutChannel(() => {}); + const opener = await createSessionOpener(); + channel?.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey }); + await settle(); + channel?.close(); + expect(answers()).toEqual([]); + await expect(requestWindowSession({}, 100)).resolves.toBeNull(); + setLockedHere(false); + await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({ activeAccountId: 'alice' }); + }); + + it('a locked pop-out takes no session from the cookies either', async () => { + setLockedHere(true); + try { + await expect(useAuthStore.getState().startSharedSession({})).resolves.toBe('no-session'); + } finally { + setLockedHere(false); + } + }); + it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => { useAuthStore.setState({ isAuthenticated: false }); render(); diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 54bf13630..7cf2ab953 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; +import { isAppLockedNow } from "@/lib/app-lock/state"; import { usePopoutsAvailable } from "@/hooks/use-popouts-available"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -76,6 +77,8 @@ export function PopoutHost() { const answerSessionRequest = async (message: Extract) => { const { accountId, rejectedFingerprint } = message; + // Locked: nobody gets a credential, whatever they ask. + if (isAppLockedNow()) return; const auth = useAuthStore.getState(); // Only a window holding the login answers: the first answer has to be usable. if (accountId && !shareableSessions().some((session) => session.accountId === accountId)) return; @@ -86,7 +89,7 @@ export function PopoutHost() { if (current && await credentialFingerprint(current) === rejectedFingerprint) await auth.refreshAccessToken({ accountId }); } const sessions = shareableSessions(); - if (sessions.length === 0) return; + if (sessions.length === 0 || isAppLockedNow()) return; const sealed = await sealFor(message.publicKey, { activeAccountId: useAuthStore.getState().activeAccountId, sessions }); channel?.post({ type: "session-answer", requestId: message.requestId, sealed }); }; diff --git a/components/settings/app-lock-settings.tsx b/components/settings/app-lock-settings.tsx new file mode 100644 index 000000000..44bb39588 --- /dev/null +++ b/components/settings/app-lock-settings.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { useTranslations } from "next-intl"; +import { CurrentPinField, NewPinFields, newPinReady } from "@/components/app-lock/pin-fields"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { useConfig } from "@/hooks/use-config"; +import { + addOsUnlock, + addPasskey, + changeAppLockPin, + disableAppLock, + enableAppLock, + hasPasskey, + lockEverywhere, + removePasskey, + setAppLockTimeout, +} from "@/lib/app-lock/controller"; +import { passkeysPossible } from "@/lib/app-lock/passkey"; +import { appLockRequired, effectiveTimeout, onLockPolicyChange } from "@/lib/app-lock/policy"; +import { readAppLockFile, TIMEOUT_CHOICES } from "@/lib/app-lock/state"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopOsUnlockKind } from "@/lib/desktop/types"; +import { IS_LITE } from "@/lib/lite"; +import { toast } from "@/stores/toast-store"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +type Pending = "enable" | "disable" | "change" | "passkey" | "os" | null; + +function useLockFacts() { + const [, refresh] = useState(0); + const update = useCallback(() => refresh((n) => n + 1), []); + useEffect(() => onLockPolicyChange(update), [update]); + const file = readAppLockFile(); + return { on: file.on, required: appLockRequired(), timeout: effectiveTimeout(file.timeoutMinutes), update }; +} + +/** The app lock's section on the General page: it has to exist on any server. */ +export function AppLockSettings() { + const t = useTranslations("appLock"); + const { appName } = useConfig(); + const { on, required, timeout, update } = useLockFacts(); + const [pending, setPending] = useState(null); + const [pin, setPin] = useState(""); + const [repeat, setRepeat] = useState(""); + const [current, setCurrent] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [passkey, setPasskey] = useState(false); + const [os, setOs] = useState<{ kind: DesktopOsUnlockKind; enrolled: boolean } | null>(null); + + const refreshMethods = useCallback(() => { + void hasPasskey().then(setPasskey).catch(() => {}); + void getDesktopBridge()?.appLock?.status() + .then((status) => setOs(status.os ? { kind: status.os, enrolled: status.osEnrolled } : null)) + .catch(() => {}); + }, []); + useEffect(refreshMethods, [refreshMethods, on]); + + const close = () => { + setPending(null); + setPin(""); + setRepeat(""); + setCurrent(""); + setError(null); + setBusy(false); + }; + + const describeFailure = (failure: unknown): string => { + const name = (failure as Error)?.name; + if (name === "WrongPinError") return t("wrong_pin"); + if (name === "PinBlockedError") return t("blocked_later"); + if (name === "PasskeyNoPrfError") return t("passkey_no_prf"); + return t("save_failed"); + }; + + const run = async (work: () => Promise) => { + setBusy(true); + setError(null); + try { + await work(); + close(); + update(); + refreshMethods(); + } catch (failure) { + setBusy(false); + setError(describeFailure(failure)); + } + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + if (pending === "enable" && newPinReady(pin, repeat)) void run(() => enableAppLock(pin)); + if (pending === "change" && current && newPinReady(pin, repeat)) void run(() => changeAppLockPin(current, pin)); + if (pending === "disable" && current) void run(() => disableAppLock(current)); + if (pending === "passkey" && current) { + void run(async () => { + await addPasskey(current, appName); + toast.success(t("passkey_added")); + }); + } + if (pending === "os" && current) { + void run(async () => { + if (!(await addOsUnlock(current))) throw new Error("Not set up"); + }); + } + }; + + if (IS_LITE) return null; + + const osName = os?.kind === "touchid" ? t("use_touchid_name") : t("use_hello_name"); + const asksNew = pending === "enable" || pending === "change"; + const asksCurrent = pending === "disable" || pending === "change" || pending === "passkey" || pending === "os"; + const ready = (!asksNew || newPinReady(pin, repeat)) && (!asksCurrent || current.length > 0); + const titles: Record, string> = { + enable: t("set_pin_title"), + change: t("change_pin_title"), + disable: t("disable_title"), + passkey: t("passkey_add_title"), + os: t("os_add_title", { method: osName }), + }; + + return ( + + + setPending(next ? "enable" : "disable")} testId="app-lock-switch" /> + + + {on && ( + <> + + + + + + + + + + + + + {passkeysPossible() && ( + + {passkey ? ( + + ) : ( + + )} + + )} + + {os && ( + + {os.enrolled ? ( + + ) : ( + + )} + + )} + + )} + + 0 || current.length > 0} + testId="app-lock-dialog" + footer={( + <> + + + + )} + > +
+ {asksCurrent && } + {asksNew && } + {!asksCurrent && error &&

{error}

} +
+
+
+ ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 370c76b2e..f0bf21780 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; 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 { ProtocolHandlerSettings } from './protocol-handler-settings'; import { useTour } from '@/components/tour/tour-provider'; @@ -26,6 +27,8 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
+ + {!IS_LITE && } diff --git a/integration/tests/24-do-app-lock.spec.ts b/integration/tests/24-do-app-lock.spec.ts new file mode 100644 index 000000000..9b76d81ac --- /dev/null +++ b/integration/tests/24-do-app-lock.spec.ts @@ -0,0 +1,411 @@ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, ENT_PASSWORD, contextOptions, deliver, jmapAs, requireEntEnv, shot, userPage } from './helpers/ent'; +import { accountSwitcher } from './helpers/app'; + +/** + * App lock (D8), through the real UI on a production build, on both test Stalwarts with + * the throwaway ent-* accounts: + * - off by default; on with a PIN in Settings > General; + * - "Lock now": a lock screen, no mail in the page, its memory gone, the store's worker + * and key gone, no plugin frames, no subjects in browser storage; + * - a wrong PIN, the wait after too many that a reload doesn't reset; + * - every tab locks and unlocks, one key: only the tab that unlocked holds it; + * - an open composer's text is on the server as a draft, and not readable meanwhile; + * - time without use across tabs; + * - a new tab, a pop-out address and a fresh start stay locked; + * - a passkey with PRF through a virtual authenticator, and one without, which keeps the PIN; + * - forgetting the PIN removes the data and signs out. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(300_000); + +const RUN = Date.now().toString(36); +const PIN = 'it-pin-4827'; +const lockForm = (page: Page) => page.getByTestId('app-lock-form'); +const SECRET_SUBJECT = `applock-secret-${RUN}`; + +test.beforeAll(async () => { + requireEntEnv(); + await deliver({ from: ENT.bob, to: [ENT.alice], subject: SECRET_SUBJECT, text: `Body of ${SECRET_SUBJECT}` }); +}); + +async function enableOffline(page: Page): Promise { + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + 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 }); +} + +async function enableLock(page: Page, pin = PIN): Promise { + await page.goto('/settings/general'); + const toggle = page.getByTestId('app-lock-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await toggle.click(); + await page.getByTestId('app-lock-new-pin').fill(pin); + await page.getByTestId('app-lock-repeat-pin').fill(pin); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true', { timeout: 30_000 }); +} + +async function lockNow(page: Page): Promise { + await page.goto('/settings/general'); + await page.getByTestId('app-lock-now').click(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); +} + +async function unlock(page: Page, pin = PIN): Promise { + await page.getByTestId('app-lock-pin').fill(pin); + await page.getByTestId('app-lock-unlock').click(); + await expect(lockForm(page)).toBeHidden({ timeout: 60_000 }); +} + +async function toMail(page: Page): Promise { + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); +} + +async function heldLocks(page: Page): Promise { + return page.evaluate(async () => ((await navigator.locks.query()).held ?? []).map((lock) => lock.name ?? '')); +} + +/** Everything a person or a script could read in plain browser storage. */ +async function browserStorage(page: Page): Promise { + return page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); +} + +/** The account key records as they are on disk. */ +async function keyRecords(page: Page): Promise> { + return page.evaluate(() => new Promise((resolve, reject) => { + const open = indexedDB.open('bulwark-store'); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const all = open.result.transaction('accounts').objectStore('accounts').getAll(); + all.onsuccess = () => { + open.result.close(); + resolve((all.result as Array<{ account: string; layers: string[] }>).map(({ account, layers }) => ({ account, layers }))); + }; + all.onerror = () => reject(all.error); + }; + })); +} + +async function serverDraftBodies(subject: string): Promise { + const client = await jmapAs(ENT.alice); + const r = await client.request([ + ['Email/query', { accountId: client.accountId, filter: { subject }, limit: 10 }, '0'], + ['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject', 'keywords', 'bodyValues', 'textBody'], fetchTextBodyValues: true }, '1'], + ]); + const list = r.methodResponses[1][1].list as Array<{ keywords: Record; bodyValues: Record }>; + return list.filter((email) => email.keywords.$draft).map((email) => Object.values(email.bodyValues).map((part) => part.value).join('\n')); +} + +/** Signed in with "Remember me", so a reload finds the session again. */ +async function signIn(page: Page): Promise { + await page.fill('#username', ENT.alice); + await page.fill('#password', ENT_PASSWORD); + await page.getByText('Remember me', { exact: true }).click(); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); +} + +async function fresh(browser: Browser): Promise<{ page: Page; context: BrowserContext }> { + const context = await browser.newContext(contextOptions()); + const page = await context.newPage(); + await page.goto('/'); + await signIn(page); + return { page, context }; +} + +test('off by default: nothing changes and no record is wrapped', async ({ browser }) => { + const { page, context } = await fresh(browser); + await page.goto('/settings/general'); + await expect(page.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 }); + expect(await page.evaluate(() => document.documentElement.hasAttribute('data-app-locked'))).toBe(false); + expect(await heldLocks(page)).not.toContain('bulwark-app-lock-holder'); + await context.close(); +}); + +test('locked: no mail in the page, its memory gone, the store closed with no key, and the PIN opens it again', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableOffline(page); + await enableLock(page); + + const records = await keyRecords(page); + expect(records.length).toBeGreaterThan(0); + for (const record of records) expect(record.layers).toEqual(['applock']); + + await toMail(page); + await expect(page.getByText(SECRET_SUBJECT).first()).toBeVisible({ timeout: 60_000 }); + await page.getByText(SECRET_SUBJECT).first().click(); + await expect(page.getByRole('heading', { name: SECRET_SUBJECT }).first()).toBeVisible({ timeout: 20_000 }); + expect(await heldLocks(page)).toContain('bulwark-store-owner'); + expect(await heldLocks(page)).toContain('bulwark-app-lock-holder'); + + await page.goto('/settings/general'); + await page.evaluate(() => { (window as unknown as { __before: string }).__before = 'still here'; }); + await page.getByTestId('app-lock-now').click(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + + expect(await page.evaluate(() => (window as unknown as { __before?: string }).__before)).toBeUndefined(); + await expect(page.locator('body')).not.toContainText(SECRET_SUBJECT); + expect(await page.content()).not.toContain(SECRET_SUBJECT); + expect(await browserStorage(page)).not.toContain(SECRET_SUBJECT); + expect(await page.title()).not.toContain(SECRET_SUBJECT); + expect(await page.locator('iframe').count(), 'no frames, plugin frames included').toBe(0); + const held = await heldLocks(page); + expect(held, 'no worker holds the store').not.toContain('bulwark-store-owner'); + expect(held, 'nobody holds the key').not.toContain('bulwark-app-lock-holder'); + // What the app below the gate would have started: nothing asks for mail. + const requests: string[] = []; + page.on('request', (request) => { if (/\/jmap|\/api\/auth\/(session|token)/.test(request.url())) requests.push(request.url()); }); + await page.waitForTimeout(1500); + expect(requests).toEqual([]); + await shot(page, 'app-lock-screen'); + + await unlock(page); + await expect(accountSwitcher(page)).toBeVisible({ timeout: 60_000 }); + expect(await heldLocks(page)).toContain('bulwark-app-lock-holder'); + await expect.poll(() => heldLocks(page), { timeout: 30_000 }).toContain('bulwark-store-owner'); + await context.close(); +}); + +test('a wrong PIN says so, and after too many the wait survives a reload', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableLock(page); + await lockNow(page); + const message = page.getByTestId('app-lock-message'); + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(page.getByTestId('app-lock-pin')).toBeEnabled({ timeout: 30_000 }); + await page.getByTestId('app-lock-pin').fill(`wrong-pin-${attempt}`); + await page.getByTestId('app-lock-unlock').click(); + await expect(page.getByTestId('app-lock-pin')).toHaveValue('', { timeout: 30_000 }); + } + await expect(message).toContainText('Too many tries', { timeout: 15_000 }); + await expect(page.getByTestId('app-lock-pin')).toBeDisabled(); + + await page.reload(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('app-lock-message')).toContainText('Too many tries', { timeout: 15_000 }); + await expect(page.getByTestId('app-lock-pin')).toBeDisabled(); + + await expect(page.getByTestId('app-lock-pin')).toBeEnabled({ timeout: 40_000 }); + await unlock(page); + await context.close(); +}); + +test('all tabs lock together, and a new tab or pop-out address stays locked; the one that unlocks holds the key', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableOffline(page); + await enableLock(page); + const second = await context.newPage(); + await second.goto('/mail'); + await expect(second.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + + await page.goto('/settings/general'); + await page.getByTestId('app-lock-now').click(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await expect(lockForm(second)).toBeVisible({ timeout: 30_000 }); + await expect(second.locator('body')).not.toContainText(SECRET_SUBJECT); + + const third = await context.newPage(); + await third.goto('/mail'); + await expect(lockForm(third)).toBeVisible({ timeout: 30_000 }); + + // A pop-out address: the lock screen sends people to the main window. + const popout = await context.newPage(); + await popout.goto('/popout/compose'); + await expect(popout.getByRole('dialog').first()).toBeVisible({ timeout: 30_000 }); + await expect(popout.locator('[data-testid="email-composer"]')).toHaveCount(0); + await expect(popout.getByRole('button', { name: 'Go to the main window' })).toBeVisible({ timeout: 10_000 }); + await popout.close(); + + await unlock(second); + await expect(lockForm(page)).toBeHidden({ timeout: 30_000 }); + await expect(lockForm(third)).toBeHidden({ timeout: 30_000 }); + await expect(accountSwitcher(page)).toBeVisible({ timeout: 60_000 }); + await expect(third.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + + // The key was never passed on: when the tab that has it goes, the others have to ask. + await second.close(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await expect(lockForm(third)).toBeVisible({ timeout: 30_000 }); + await unlock(page); + await context.close(); +}); + +test('an open composer\'s text is saved first, is unreadable while locked, and nothing prompts', async ({ browser }) => { + const { page, context } = await fresh(browser); + const subject = `applock-draft-${RUN}`; + const words = `words only the draft has ${RUN}`; + await enableLock(page); + await toMail(page); + const prompts: string[] = []; + page.on('dialog', (dialog) => { prompts.push(dialog.type()); void dialog.accept(); }); + await page.keyboard.press('c'); + const composer = page.locator('[data-testid="email-composer"]'); + await composer.waitFor({ state: 'visible', timeout: 15_000 }); + await page.locator('[data-testid="composer-subject"]').first().fill(subject); + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.type(words); + + // As the timer does it, with the composer open. + await page.evaluate(() => { + const raw = JSON.parse(localStorage.getItem('bulwark-app-lock') ?? '{}'); + localStorage.setItem('bulwark-app-lock', JSON.stringify({ ...raw, locked: true })); + new BroadcastChannel('bulwark:app-lock').postMessage({ type: 'lock' }); + }); + await expect(lockForm(page)).toBeVisible({ timeout: 60_000 }); + expect(prompts).toEqual([]); + await expect(page.locator('body')).not.toContainText(words); + expect(await browserStorage(page)).not.toContain(words); + + await expect.poll(async () => (await serverDraftBodies(subject)).join('\n'), { timeout: 60_000 }).toContain(words); + + await unlock(page); + await expect(page.getByRole('status').filter({ hasText: 'saved to Drafts' }).or(page.getByText('saved to Drafts')).first()).toBeVisible({ timeout: 30_000 }); + await context.close(); +}); + +test('time without use locks every tab, and use in another tab counts', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableLock(page); + await page.goto('/settings/general'); + await page.getByTestId('app-lock-timeout').selectOption('1'); + const other = await context.newPage(); + await other.goto('/mail'); + await expect(other.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + + // 40 s of use in the other tab, then nothing. + for (let i = 0; i < 4; i += 1) { + await other.mouse.move(100 + i * 5, 100); + await other.waitForTimeout(10_000); + await other.mouse.move(120 + i * 5, 120); + } + await page.waitForTimeout(25_000); + await expect(lockForm(page), 'used 25 s ago in the other tab').toBeHidden(); + + await expect(lockForm(page)).toBeVisible({ timeout: 90_000 }); + await expect(lockForm(other)).toBeVisible({ timeout: 30_000 }); + await context.close(); +}); + +test('a browser closed while unlocked starts locked', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableLock(page); + const state = await context.storageState(); + // Web Locks and memory don't survive this. IndexedDB doesn't come along either, so localStorage alone decides here. + await context.close(); + const reopened = await browser.newContext({ ...contextOptions(), storageState: state }); + const again = await reopened.newPage(); + await again.goto('/mail'); + await expect(lockForm(again)).toBeVisible({ timeout: 30_000 }); + await expect(again.locator('[data-testid="email-list-item"]')).toHaveCount(0); + await reopened.close(); +}); + +async function virtualAuthenticator(page: Page, hasPrf: boolean): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send('WebAuthn.enable', { enableUI: false }); + await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + ctap2Version: 'ctap2_1', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + hasPrf, + }, + }); +} + +test('a passkey with PRF unlocks without the PIN, and the PIN keeps working', async ({ browser }) => { + const { page, context } = await fresh(browser); + await virtualAuthenticator(page, true); + await enableOffline(page); + await enableLock(page); + await page.getByTestId('app-lock-add-passkey').click(); + await page.getByTestId('app-lock-current-pin').fill(PIN); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(page.getByTestId('app-lock-dialog')).toBeHidden({ timeout: 60_000 }); + + await lockNow(page); + await page.getByTestId('app-lock-passkey').click(); + await expect(lockForm(page)).toBeHidden({ timeout: 60_000 }); + await expect(accountSwitcher(page)).toBeVisible({ timeout: 60_000 }); + await expect.poll(() => heldLocks(page), { timeout: 30_000 }).toContain('bulwark-store-owner'); + + await lockNow(page); + await unlock(page); + await context.close(); +}); + +test('a passkey with no PRF is refused when added, and the PIN stays', async ({ browser }) => { + const { page, context } = await fresh(browser); + await virtualAuthenticator(page, false); + await enableLock(page); + await page.getByTestId('app-lock-add-passkey').click(); + await page.getByTestId('app-lock-current-pin').fill(PIN); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(page.getByTestId('app-lock-dialog')).toContainText("can't protect the key", { timeout: 60_000 }); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await lockNow(page); + await expect(page.getByTestId('app-lock-passkey')).toHaveCount(0); + await unlock(page); + await context.close(); +}); + +test('a login without "Remember me" has to sign in again after a lock, and its local data waits', async ({ browser }) => { + const page = await userPage(browser, ENT.alice); + const context = page.context(); + await enableOffline(page); + await enableLock(page); + const before = await keyRecords(page); + expect(before.length).toBeGreaterThan(0); + await lockNow(page); + await page.getByTestId('app-lock-pin').fill(PIN); + await page.getByTestId('app-lock-unlock').click(); + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + // Not what a page reload does to such a login with the lock off: that one wipes. + expect((await keyRecords(page)).map((record) => record.account)).toEqual(before.map((record) => record.account)); + await signIn(page); + await expect.poll(() => heldLocks(page), { timeout: 60_000 }).toContain('bulwark-store-owner'); + await context.close(); +}); + +test('turning it off needs the PIN and takes the layer off', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableOffline(page); + await enableLock(page); + await page.getByTestId('app-lock-switch').click(); + await page.getByTestId('app-lock-current-pin').fill('wrong-pin-1'); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(page.getByTestId('app-lock-dialog')).toContainText('not right', { timeout: 30_000 }); + await page.getByTestId('app-lock-current-pin').fill(PIN); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(page.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 }); + for (const record of await keyRecords(page)) expect(record.layers).toEqual([]); + await page.reload(); + await expect(accountSwitcher(page).or(page.getByTestId('app-lock-switch')).first()).toBeVisible({ timeout: 60_000 }); + await context.close(); +}); + +test('a forgotten PIN: local data goes, everyone signs out, and the login page is next', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableOffline(page); + await enableLock(page); + await lockNow(page); + await page.getByTestId('app-lock-forgot').click(); + await page.getByRole('button', { name: 'Remove and sign out' }).click(); + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + expect(await page.evaluate(() => localStorage.getItem('bulwark-app-lock'))).toBeNull(); + const databases = await page.evaluate(async () => (await indexedDB.databases()).map((db) => db.name)); + expect(databases).not.toContain('bulwark-app-lock'); + await context.close(); +}); diff --git a/lib/__tests__/sw-push-app-lock.test.ts b/lib/__tests__/sw-push-app-lock.test.ts new file mode 100644 index 000000000..8e795a1b7 --- /dev/null +++ b/lib/__tests__/sw-push-app-lock.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest'; +import { FakeCaches, loadWorker } from './sw-harness'; + +// App lock: while locked a notification names no sender and no subject, and the +// worker doesn't even ask the server for them. It can't hear a browser closing, so +// "unlocked" only counts with a window open. + +const PREVIEW = { unreadTotal: 1, slot: 0, email: { id: 'e1', threadId: 't1', subject: 'Secret subject', from: [{ name: 'Secret sender', email: 's@example.org' }] } }; +const delivery = { kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }; + +function setup(clients: Array<{ id: string }>, notifications: Array> = []) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(PREVIEW), { status: 200 })); + const worker = loadWorker({ buildId: 'b', caches: new FakeCaches(), clients, fetch: fetchMock, notifications }); + return { worker, fetchMock }; +} + +describe('a push while the app lock is on', () => { + it('names the sender with no lock at all', async () => { + const { worker, fetchMock } = setup([]); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(fetchMock).toHaveBeenCalled(); + expect(worker.shown[0].title).toContain('Secret sender'); + }); + + it('says nothing about the message while locked, and asks nothing', async () => { + const { worker, fetchMock } = setup([{ id: 'c' }]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } }); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(JSON.stringify(worker.shown)).not.toContain('Secret'); + expect(worker.shown[0].title).toBe('New mail'); + }); + + it('names it again after unlock, with a window open', async () => { + const { worker } = setup([{ id: 'c' }]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } }); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'unlocked' } }); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(worker.shown[0].title).toContain('Secret sender'); + }); + + it('counts as locked with no window open: the key is in a window or nowhere', async () => { + const { worker, fetchMock } = setup([]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'unlocked' } }); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(JSON.stringify(worker.shown)).not.toContain('Secret'); + }); + + it('names it again once the lock is off', async () => { + const { worker } = setup([]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } }); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'off' } }); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(worker.shown[0].title).toContain('Secret sender'); + }); + + it('closes what\'s already on screen when it locks', async () => { + const close = vi.fn(); + const { worker } = setup([{ id: 'c' }], [ + { tag: 'bulwark-mail:a', title: 'Secret sender', close }, + ]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } }); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/lib/app-lock/__tests__/controller.test.ts b/lib/app-lock/__tests__/controller.test.ts new file mode 100644 index 000000000..1c6a03da4 --- /dev/null +++ b/lib/app-lock/__tests__/controller.test.ts @@ -0,0 +1,471 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { composeSnapshots, registerComposeFlush, registerComposeSnapshot, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; +import { isAppLocked, setAppLockCheck } from '@/lib/desktop/app-lock-state'; +import { configureStore, getStore, setStoreBackend, type StorePageHooks } from '@/lib/store/client/api'; +import { deleteKeyDatabase, getAccountRecord, openAccountKey } from '@/lib/store/client/keys'; +import { + addOsUnlock, + appLockHold, + appLockKeyLayers, + bootAppLock, + disableAppLock, + enableAppLock, + forgetEverything, + getAppLockPhase, + lockEverywhere, + resetAppLockForTests, + setReloadForTests, + startAppLock, + unlockWithOs, + unlockWithPin, +} from '../controller'; +import { appLockRequired, effectiveTimeout } from '../policy'; +import { APP_LOCK_KEY, isAppLockedNow, readAppLockFile, setLockedHere } from '../state'; +import { deleteVault } from '../vault'; + +const PIN = 'correct-pin'; + +class FakeChannel { + static open = new Set(); + onmessage: ((event: MessageEvent) => void) | null = null; + constructor(readonly name: string) { + FakeChannel.open.add(this); + } + postMessage(data: unknown): void { + for (const channel of FakeChannel.open) { + if (channel !== this && channel.name === this.name) channel.onmessage?.({ data } as MessageEvent); + } + } + close(): void { + FakeChannel.open.delete(this); + } +} + +class FakeWorker { + static all: FakeWorker[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + posted: Array> = []; + terminated = false; + constructor() { + FakeWorker.all.push(this); + // As the real one does with no other tab around. + queueMicrotask(() => this.onmessage?.({ data: { kind: 'owner' } } as MessageEvent)); + } + postMessage(message: Record): void { + this.posted.push(message); + if (message.kind === 'open' || message.kind === 'release' || message.kind === 'prune-files') { + queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: message.kind === 'open' ? { created: false } : undefined } } as MessageEvent)); + } + } + terminate(): void { + this.terminated = true; + } +} + +const hooks: StorePageHooks = { + inputs: () => ({ session: { apiUrl: 'x' } as never, auth: 'Bearer token', invertedKeywordSort: null }), + refreshAuth: async () => null, + onEvent: () => {}, + keyLayers: appLockKeyLayers, +}; + +let reloads = 0; +let stopTab: () => void = () => {}; + +function bridge(overrides: Record = {}) { + const appLock = { + status: vi.fn(async () => ({ locked: false, enrolled: true, os: 'hello', osEnrolled: true })), + lock: vi.fn(), + enroll: vi.fn(async () => true), + prove: vi.fn(async () => true), + disable: vi.fn(async () => true), + forget: vi.fn(async () => true), + osEnroll: vi.fn(async () => true), + osRemove: vi.fn(async () => {}), + osUnlock: vi.fn(async () => ({ ok: false as const, reason: 'denied' as const })), + onLockRequest: vi.fn(() => () => {}), + ...overrides, + }; + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', appLock }; + return appLock; +} + +beforeEach(async () => { + FakeChannel.open.clear(); + FakeWorker.all = []; + vi.stubGlobal('BroadcastChannel', FakeChannel); + vi.stubGlobal('Worker', FakeWorker); + vi.stubGlobal('FileSystemFileHandle', class {}); + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200 }))); + Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory: async () => ({}) } }); + Object.defineProperty(window.navigator, 'locks', { + configurable: true, + value: { + request: vi.fn(async (_name: string, ...rest: unknown[]) => { + const run = rest[rest.length - 1] as () => unknown; + return run(); + }), + query: vi.fn(async () => ({ held: [], pending: [] })), + }, + }); + localStorage.clear(); + sessionStorage.clear(); + reloads = 0; + setReloadForTests(() => { reloads += 1; }); + resetAppLockForTests(); + resetComposeFlushRegistry(); + setStoreBackend(null); + configureStore(hooks); + await deleteVault(); + deleteKeyDatabase(); +}); + +afterEach(() => { + stopTab(); + stopTab = () => {}; + vi.unstubAllGlobals(); + setReloadForTests(null); + setAppLockCheck(null); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + delete (window.navigator as { storage?: unknown }).storage; + delete (window.navigator as { locks?: unknown }).locks; + setStoreBackend(null); + configureStore(null); + setLockedHere(false); +}); + +/** A store open under the lock, as a running tab has it. */ +async function runningLockedStore(account = 'acc') { + await enableAppLock(PIN); + await getStore().ensureOpen(account, { apiUrl: 'x' } as never); + await vi.waitFor(() => expect(lastWorker().posted.some((m) => m.kind === 'open')).toBe(true)); + return lastWorker(); +} + +function lastWorker(): FakeWorker { + return FakeWorker.all[FakeWorker.all.length - 1]; +} + +async function lockedAndReloaded(): Promise { + lockEverywhere(); + await vi.waitFor(() => expect(reloads).toBe(1)); +} + +/** The page a reload gives: new module state, the same storage. */ +function asNewDocument(): void { + resetAppLockForTests(); + bootAppLock(); +} + +describe('while locked', () => { + it('the worker with the key is gone, the key with it, and reads fail until unlock', async () => { + const worker = await runningLockedStore(); + const opened = worker.posted.find((m) => m.kind === 'open') as { params: { keyHex: string } }; + expect(opened.params.keyHex).toMatch(/^[0-9a-f]{64}$/); + + await lockedAndReloaded(); + + expect(worker.posted.some((m) => m.kind === 'release')).toBe(true); + expect(worker.terminated).toBe(true); + const workers = FakeWorker.all.length; + await expect(getStore().answer('acc', [])).rejects.toMatchObject({ name: 'StoreLockedError' }); + await expect(getStore().area('acc', 'x.y')).rejects.toMatchObject({ name: 'StoreLockedError' }); + expect(FakeWorker.all.length, 'no new worker while locked').toBe(workers); + // In this tab's memory too: nothing gives the layer now. + expect(() => appLockKeyLayers()).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); + await expect(openAccountKey('acc')).rejects.toMatchObject({ name: 'StoreKeyLayerMissingError' }); + + await unlockWithPin(PIN); + expect(isAppLockedNow()).toBe(false); + await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toMatchObject({ keyHex: opened.params.keyHex }); + }); + + it('a stuck close doesn\'t keep the key: the worker is stopped after the wait', async () => { + const worker = await runningLockedStore(); + worker.postMessage = function postMessage(message) { this.posted.push(message); }; + await getStore().lock?.(10); + expect(worker.terminated).toBe(true); + }); + + it('a new start stays closed: the app is held back and nothing has the key', async () => { + await enableAppLock(PIN); + await lockedAndReloaded(); + asNewDocument(); + expect(getAppLockPhase()).toBe('locked'); + expect(appLockHold()).toBeInstanceOf(Promise); + expect(isAppLockedNow()).toBe(true); + }); + + it('notifications go by it: the check the OS code asks says locked', async () => { + await enableAppLock(PIN); + asNewDocument(); + expect(isAppLocked()).toBe(true); + }); + + it('a browser closed without locking counts as locked: nobody holds the key', async () => { + await enableAppLock(PIN); + resetAppLockForTests(); + expect(readAppLockFile().locked).toBe(false); + bootAppLock(); + expect(getAppLockPhase()).toBe('checking'); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); + expect(readAppLockFile().locked).toBe(true); + }); + + it('another tab holding the key lets this one start without any key', async () => { + await enableAppLock(PIN); + resetAppLockForTests(); + (window.navigator.locks.query as ReturnType).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] }); + // Held elsewhere: a request for it waits. + (window.navigator.locks.request as ReturnType).mockImplementation(() => new Promise(() => {})); + bootAppLock(); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('open')); + expect(() => appLockKeyLayers()).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); + }); +}); + +describe('locking', () => { + it('saves what is being written before the store closes', async () => { + const worker = await runningLockedStore(); + const order: string[] = []; + registerComposeFlush(async () => { order.push(worker.terminated ? 'saved after close' : 'saved'); }); + await lockedAndReloaded(); + expect(order).toEqual(['saved']); + }); + + it('seals text the server didn\'t take, and it comes back only after unlock', async () => { + await enableAppLock(PIN); + registerComposeFlush(async () => { throw new Error('offline'); }); + registerComposeSnapshot({ ownWindow: false, state: () => ({ subject: 'Unsaved secret', unsaved: true }) as never, draft: () => null }); + await lockedAndReloaded(); + expect(JSON.stringify({ ...localStorage })).not.toContain('Unsaved secret'); + expect(JSON.stringify({ ...sessionStorage })).not.toContain('Unsaved secret'); + await unlockWithPin(PIN); + await vi.waitFor(() => expect(sessionStorage.getItem('bulwark-rescued-drafts')).toContain('Unsaved secret')); + }); + + it('takes subjects out of plain browser storage', async () => { + await enableAppLock(PIN); + localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}'); + localStorage.setItem('pro-tabs', '{"tabs":[{"title":"Secret"}]}'); + sessionStorage.setItem('bulwark-popout-compose:h1', '{"initialData":{"subject":"Secret"}}'); + await lockedAndReloaded(); + expect(localStorage.getItem('email-snapshot')).toBeNull(); + expect(localStorage.getItem('pro-tabs')).toBeNull(); + expect(sessionStorage.getItem('bulwark-popout-compose:h1')).toBeNull(); + }); + + it('points a composer window at its saved draft, so nothing of the text stays in its address or session', async () => { + await enableAppLock(PIN); + registerComposeSnapshot({ ownWindow: true, state: () => ({ subject: 'x' }) as never, draft: () => ({ draftId: 'd7', localAccountId: 'alice' }) }); + await lockedAndReloaded(); + expect(window.location.search).toContain('draft=d7'); + expect(sessionStorage.getItem('bulwark-app-lock-drafts-saved')).toBe('1'); + expect(composeSnapshots()).toHaveLength(1); + window.history.replaceState(null, '', '/'); + }); + + it('reaches this tab from another, by the channel', async () => { + await enableAppLock(PIN); + stopTab = startAppLock(); + new FakeChannel('bulwark:app-lock').postMessage({ type: 'lock' }); + await vi.waitFor(() => expect(reloads).toBe(1)); + expect(isAppLockedNow()).toBe(true); + }); + + it('when the server is out of reach it stays on the page, off the screen, and reloads when it can', async () => { + (fetch as ReturnType).mockRejectedValue(new Error('offline')); + await enableAppLock(PIN); + lockEverywhere(); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay')); + expect(reloads).toBe(0); + window.dispatchEvent(new Event('online')); + expect(reloads).toBe(1); + }); +}); + +describe('the store when a tab inherits it without the key', () => { + it('locks everywhere instead of opening or writing an unwrapped key', async () => { + await enableAppLock(PIN); + resetAppLockForTests(); + bootAppLock(); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); + const layers = () => appLockKeyLayers(); + expect(layers).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); + // A new account can't get a key record outside the layer either. + expect(await getAccountRecord('new-account')).toBeNull(); + }); + + it('a new account after unlock gets its key under the lock', async () => { + await enableAppLock(PIN); + await getStore().ensureOpen('new-account', { apiUrl: 'x' } as never); + await vi.waitFor(async () => expect((await getAccountRecord('new-account'))?.layers).toEqual(['applock'])); + }); +}); + +describe('the desktop app', () => { + it('a PIN unlock proves itself to main, and nothing can just say unlocked', async () => { + const appLock = bridge(); + await enableAppLock(PIN); + expect(appLock.enroll).toHaveBeenCalledTimes(1); + const proof = appLock.enroll.mock.calls[0][0 as never] as unknown as Uint8Array; + await lockedAndReloaded(); + expect(appLock.lock).toHaveBeenCalled(); + await unlockWithPin(PIN); + expect(appLock.prove).toHaveBeenCalledTimes(1); + expect(Array.from(appLock.prove.mock.calls[0][0 as never] as unknown as Uint8Array)).toEqual(Array.from(proof)); + }); + + it('the screen locking locks the app', async () => { + let request: () => void = () => {}; + bridge({ onLockRequest: vi.fn((listener: () => void) => { request = listener; return () => {}; }) }); + await enableAppLock(PIN); + stopTab = startAppLock(); + request(); + await vi.waitFor(() => expect(reloads).toBe(1)); + expect(readAppLockFile().locked).toBe(true); + }); + + it('a page in a tab can\'t lift a lock main still holds', async () => { + const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) }); + await enableAppLock(PIN); + resetAppLockForTests(); + bootAppLock(); + stopTab = startAppLock(); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); + new FakeChannel('bulwark:app-lock').postMessage({ type: 'unlocked' }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(appLock.status).toHaveBeenCalled(); + expect(getAppLockPhase()).toBe('locked'); + }); + + it('Windows Hello\'s yes opens with the key main hands over', async () => { + let held: Uint8Array | null = null; + const appLock = bridge({ + osEnroll: vi.fn(async (_proof: Uint8Array, key: Uint8Array) => { held = key.slice(); return true; }), + osUnlock: vi.fn(async () => ({ ok: true as const, key: held! })), + }); + await runningLockedStore(); + expect(await addOsUnlock(PIN)).toBe(true); + await lockedAndReloaded(); + expect(await unlockWithOs()).toBe(true); + expect(appLock.osUnlock).toHaveBeenCalledTimes(1); + expect(isAppLockedNow()).toBe(false); + await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toBeDefined(); + }); + + it('wrong bytes from main open nothing', async () => { + bridge({ osUnlock: vi.fn(async () => ({ ok: true as const, key: new Uint8Array(32).fill(1) })) }); + await runningLockedStore(); + await lockedAndReloaded(); + await unlockWithOs().catch(() => false); + await expect(openAccountKey('acc', appLockKeyLayers())).rejects.toBeDefined(); + }); + + it('Windows Hello\'s no leaves it locked, and the PIN still works', async () => { + const appLock = bridge(); + await enableAppLock(PIN); + await lockedAndReloaded(); + expect(await unlockWithOs()).toBe(false); + expect(appLock.osUnlock).toHaveBeenCalled(); + expect(isAppLockedNow()).toBe(true); + await unlockWithPin(PIN); + expect(isAppLockedNow()).toBe(false); + }); + + it('forgetting asks main first: a no changes nothing', async () => { + const appLock = bridge({ forget: vi.fn(async () => false) }); + await enableAppLock(PIN); + await lockedAndReloaded(); + expect(await forgetEverything()).toBe(false); + expect(appLock.forget).toHaveBeenCalled(); + expect(readAppLockFile()).toMatchObject({ on: true, locked: true }); + }); +}); + +describe('policy', () => { + function withPolicy(required: { value: boolean; locked: boolean }, minutes: { value: number | null; locked: boolean } = { value: null, locked: false }) { + const appLock = bridge(); + (window as unknown as { bulwarkDesktop: Record }).bulwarkDesktop.policy = { + get: () => ({ + AppLockRequired: { ...required, source: 'forced-machine' }, + AppLockTimeoutMinutes: { ...minutes, source: minutes.locked ? 'forced-machine' : 'default' }, + }), + onChange: () => () => {}, + }; + return appLock; + } + + it('a forced policy requires it: a signed-in start waits for a PIN', () => { + withPolicy({ value: true, locked: true }); + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } })); + expect(appLockRequired()).toBe(true); + bootAppLock(); + expect(getAppLockPhase()).toBe('setup'); + expect(appLockHold()).toBeInstanceOf(Promise); + }); + + it('a required lock doesn\'t stand in front of the login page', () => { + withPolicy({ value: true, locked: true }); + bootAppLock(); + expect(getAppLockPhase()).toBe('open'); + }); + + it('a recommended policy requires nothing', () => { + withPolicy({ value: true, locked: false }); + expect(appLockRequired()).toBe(false); + }); + + it('a required lock can\'t be turned off', async () => { + await enableAppLock(PIN); + withPolicy({ value: true, locked: true }); + await expect(disableAppLock(PIN)).rejects.toThrow(); + expect(readAppLockFile().on).toBe(true); + }); + + it('setting up the required PIN lets the app go on', async () => { + withPolicy({ value: true, locked: true }); + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } })); + bootAppLock(); + const hold = appLockHold(); + await enableAppLock(PIN); + await expect(hold).resolves.toBeUndefined(); + expect(getAppLockPhase()).toBe('open'); + }); + + it('a forced timeout wins over the user\'s choice, a recommended one only over no choice', () => { + withPolicy({ value: false, locked: false }, { value: 3, locked: true }); + expect(effectiveTimeout(60)).toEqual({ minutes: 3, locked: true }); + withPolicy({ value: false, locked: false }, { value: 3, locked: false }); + expect(effectiveTimeout(60)).toEqual({ minutes: 60, locked: false }); + expect(effectiveTimeout(undefined)).toEqual({ minutes: 3, locked: false }); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + expect(effectiveTimeout(undefined)).toEqual({ minutes: 10, locked: false }); + }); +}); + +describe('off', () => { + it('is the default: nothing is held back and no layer is asked for', () => { + bootAppLock(); + expect(getAppLockPhase()).toBe('open'); + expect(appLockHold()).toBeNull(); + expect(appLockKeyLayers()).toEqual([]); + expect(localStorage.getItem(APP_LOCK_KEY)).toBeNull(); + }); + + it('turning it off takes the layer off again', async () => { + await openAccountKey('acc'); + await enableAppLock(PIN); + expect((await getAccountRecord('acc'))?.layers).toEqual(['applock']); + await disableAppLock(PIN); + expect((await getAccountRecord('acc'))?.layers).toEqual([]); + expect(readAppLockFile().on).toBe(false); + }); + + it('a wrong PIN doesn\'t turn it off', async () => { + await enableAppLock(PIN); + await expect(disableAppLock('wrong-pin')).rejects.toMatchObject({ name: 'WrongPinError' }); + expect(readAppLockFile().on).toBe(true); + }); +}); diff --git a/lib/app-lock/__tests__/idle.test.ts b/lib/app-lock/__tests__/idle.test.ts new file mode 100644 index 000000000..7075fa931 --- /dev/null +++ b/lib/app-lock/__tests__/idle.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { lastActivity, noteActivity, watchIdle } from '../idle'; + +const MINUTE = 60_000; + +describe('time without use', () => { + let stop: () => void = () => {}; + + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + }); + + afterEach(() => { + stop(); + vi.useRealTimers(); + }); + + function watch(minutes: number) { + const onIdle = vi.fn(); + stop = watchIdle({ timeoutMs: () => minutes * MINUTE, onIdle }); + return onIdle; + } + + it('locks once the time has passed with nothing happening', () => { + const onIdle = watch(5); + vi.advanceTimersByTime(5 * MINUTE - 20_000); + expect(onIdle).not.toHaveBeenCalled(); + vi.advanceTimersByTime(30_000); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('using this window starts it over', () => { + const onIdle = watch(5); + vi.advanceTimersByTime(4 * MINUTE); + window.dispatchEvent(new KeyboardEvent('keydown')); + vi.advanceTimersByTime(4 * MINUTE); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it('using another window or tab counts too', () => { + const onIdle = watch(5); + vi.advanceTimersByTime(4 * MINUTE); + // Another tab writes the shared key. + noteActivity(Date.now()); + vi.advanceTimersByTime(4 * MINUTE); + expect(onIdle).not.toHaveBeenCalled(); + vi.advanceTimersByTime(2 * MINUTE); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('tells the other tabs about use here, but not on every mouse move', () => { + watch(5); + const before = lastActivity(); + vi.advanceTimersByTime(1000); + window.dispatchEvent(new Event('pointermove')); + expect(lastActivity()).toBe(before); + vi.advanceTimersByTime(5000); + window.dispatchEvent(new Event('pointermove')); + expect(lastActivity()).toBe(Date.now()); + }); + + it('coming back after the time counts as too late, not as use', () => { + const onIdle = watch(5); + // The machine slept: no timer ran, and the clock jumped. + vi.setSystemTime(Date.now() + 6 * MINUTE); + window.dispatchEvent(new KeyboardEvent('keydown')); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('a changed timeout applies at once', () => { + let minutes = 60; + const onIdle = vi.fn(); + stop = watchIdle({ timeoutMs: () => minutes * MINUTE, onIdle }); + vi.advanceTimersByTime(2 * MINUTE); + expect(onIdle).not.toHaveBeenCalled(); + minutes = 1; + vi.advanceTimersByTime(10_000); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('fires once and stops listening', () => { + const onIdle = watch(1); + vi.advanceTimersByTime(2 * MINUTE); + vi.advanceTimersByTime(10 * MINUTE); + window.dispatchEvent(new KeyboardEvent('keydown')); + expect(onIdle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/app-lock/__tests__/vault.test.ts b/lib/app-lock/__tests__/vault.test.ts new file mode 100644 index 000000000..f1173575d --- /dev/null +++ b/lib/app-lock/__tests__/vault.test.ts @@ -0,0 +1,158 @@ +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAccountRecord, openAccountKey, rewrapAccountKey, StoreKeyLayerMissingError } from '@/lib/store/client/keys'; +import { importLockKey, lockLayer, mainProof, PBKDF2_ITERATIONS, pinLongEnough } from '../crypto'; +import { createVault, deleteVault, FREE_FAILURES, LONGEST_WAIT_MS, openSealedDrafts, openVaultWithPin, PinBlockedError, pinBlockedUntil, readVault, sealDraft, waitAfter, WrongPinError } from '../vault'; + +const PIN = 'correct-pin'; + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +beforeEach(async () => { + await deleteVault(); +}); + +describe('the PIN', () => { + it('uses PBKDF2 with at least 600,000 iterations and a random salt', async () => { + (await createVault(PIN)).fill(0); + const first = await readVault(); + expect(first?.pin.iterations).toBeGreaterThanOrEqual(600_000); + expect(PBKDF2_ITERATIONS).toBeGreaterThanOrEqual(600_000); + (await createVault(PIN)).fill(0); + const second = await readVault(); + expect(hex(second!.pin.salt)).not.toBe(hex(first!.pin.salt)); + }); + + it('opens the lock key with the right PIN and not with a wrong one', async () => { + const raw = await createVault(PIN); + expect(hex(await openVaultWithPin(PIN))).toBe(hex(raw)); + await expect(openVaultWithPin('wrong-pin')).rejects.toBeInstanceOf(WrongPinError); + }); + + it('keeps nothing readable: the lock key isn\'t in what is stored', async () => { + const raw = await createVault(PIN); + const stored = await readVault(); + const bytes = hex(stored!.pin.data); + expect(bytes).not.toContain(hex(raw)); + expect(JSON.stringify(stored)).not.toContain(PIN); + }); + + it('must be long enough, counted in characters', () => { + expect(pinLongEnough('12345')).toBe(false); + expect(pinLongEnough('123456')).toBe(true); + }); +}); + +describe('wrong PINs', () => { + const NOW = 1_700_000_000_000; + + async function failTimes(count: number, now = NOW): Promise { + for (let i = 0; i < count; i += 1) { + await openVaultWithPin('wrong-pin', now + i).catch((error) => { + if ((error as Error).name !== 'WrongPinError') throw error; + }); + } + } + + it('cost nothing at first, then a growing wait, up to a long one and no wipe', () => { + expect(waitAfter(FREE_FAILURES)).toBe(0); + expect(waitAfter(FREE_FAILURES + 1)).toBe(30_000); + expect(waitAfter(FREE_FAILURES + 2)).toBe(60_000); + expect(waitAfter(1000)).toBe(LONGEST_WAIT_MS); + }); + + it('block the next try even with the right PIN', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES + 1); + await expect(openVaultWithPin(PIN, NOW + FREE_FAILURES + 2)).rejects.toBeInstanceOf(PinBlockedError); + }); + + it('are still counted after a reload', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES + 1); + vi.resetModules(); + const fresh = await import('../vault'); + await expect(fresh.openVaultWithPin(PIN, NOW + 100)).rejects.toMatchObject({ name: 'PinBlockedError' }); + expect(await fresh.pinBlockedUntil(NOW + 100)).toBeGreaterThan(NOW + 100); + }); + + it('are counted before the check, so leaving during one gains nothing', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES); + // A check that never finishes: the page was closed while PBKDF2 ran. + const derive = vi.spyOn(crypto.subtle, 'deriveKey').mockImplementationOnce(() => new Promise(() => {})); + void openVaultWithPin('wrong-pin', NOW + 50); + await vi.waitFor(() => expect(derive).toHaveBeenCalled()); + derive.mockRestore(); + expect(await pinBlockedUntil(NOW + 51)).toBeGreaterThan(NOW + 51); + }); + + it('are forgotten after the right PIN', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES); + await openVaultWithPin(PIN, NOW + 100); + expect(await pinBlockedUntil(NOW + 200)).toBe(0); + await failTimes(FREE_FAILURES, NOW + 200); + expect(await pinBlockedUntil(NOW + 300)).toBe(0); + }); + + it('count again after the wait, for longer', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES + 1); + const later = NOW + 31_000; + await expect(openVaultWithPin('wrong-pin', later)).rejects.toBeInstanceOf(WrongPinError); + expect(await pinBlockedUntil(later)).toBe(later + 60_000); + }); +}); + +describe('the account keys under the lock', () => { + it('can\'t be read from disk without the lock key, and read back the same with it', async () => { + const { keyHex } = await openAccountKey('acc-layer'); + const raw = await createVault(PIN); + const layer = lockLayer(await importLockKey(raw)); + await rewrapAccountKey('acc-layer', [layer], [layer]); + + const record = await getAccountRecord('acc-layer'); + expect(record?.layers).toEqual(['applock']); + expect(hex(record!.wrapped)).not.toContain(keyHex); + await expect(openAccountKey('acc-layer')).rejects.toBeInstanceOf(StoreKeyLayerMissingError); + + const other = lockLayer(await importLockKey(new Uint8Array(32).fill(9))); + await expect(openAccountKey('acc-layer', [other])).rejects.toBeDefined(); + + expect((await openAccountKey('acc-layer', [layer])).keyHex).toBe(keyHex); + }); + + it('come off the layer again without changing the database key', async () => { + const { keyHex } = await openAccountKey('acc-off'); + const raw = await createVault(PIN); + const layer = lockLayer(await importLockKey(raw)); + await rewrapAccountKey('acc-off', [layer], [layer]); + await rewrapAccountKey('acc-off', [layer], []); + expect((await getAccountRecord('acc-off'))?.layers).toEqual([]); + expect((await openAccountKey('acc-off')).keyHex).toBe(keyHex); + }); +}); + +describe('a draft a locking window couldn\'t save', () => { + it('can be sealed with no key, and only the lock key opens it', async () => { + const raw = await createVault(PIN); + const key = await importLockKey(raw); + await sealDraft({ subject: 'Secret plan' }); + await expect(openSealedDrafts(await importLockKey(new Uint8Array(32).fill(1)))).rejects.toBeDefined(); + expect(await openSealedDrafts(key)).toEqual([{ subject: 'Secret plan' }]); + expect(await openSealedDrafts(key)).toEqual([]); + }); +}); + +describe('the proof for the desktop app', () => { + it('follows from the lock key and isn\'t the lock key', async () => { + const raw = new Uint8Array(32).fill(3); + const proof = await mainProof(raw); + expect(hex(proof)).not.toBe(hex(raw)); + expect(hex(await mainProof(raw))).toBe(hex(proof)); + expect(hex(await mainProof(new Uint8Array(32).fill(4)))).not.toBe(hex(proof)); + }); +}); diff --git a/lib/app-lock/controller.ts b/lib/app-lock/controller.ts new file mode 100644 index 000000000..46c71533c --- /dev/null +++ b/lib/app-lock/controller.ts @@ -0,0 +1,730 @@ +/** + * App lock in one tab: when it locks, what locking does, what unlocking does. + * + * Locking saves what's being written, closes the store, drops the keys, then reloads + * the page. The reload is how memory gets cleared: no list of stores to reset can + * be as complete as a new document, and it takes the plugin frames, the client's + * credentials and every module cache along. The new document stops at the lock screen + * (components/app-lock/app-lock-gate.tsx), so nothing starts until someone unlocks. + * + * The lock key lives in the memory of the tab that unlocked, and in no other. It + * never goes over a channel. That tab holds a Web Lock for as long as it lives. When it + * goes, whichever tab inherits the store couldn't open it, so every tab locks. + * + * In the desktop app, main has the last word on whether the app is locked (see + * the shell's src/main/app-lock-gate.ts). A page can tell it to lock and can prove + * the real unlock, and has no way to just say "unlocked". + */ + +import { flushComposers, composeSnapshots } from '@/lib/compose-flush-registry'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { setAppLockCheck } from '@/lib/desktop/app-lock-state'; +import { restoreUrlFor } from '@/lib/desktop/window-restore'; +import { IS_LITE } from '@/lib/lite'; +import { keepRescuedDraft } from '@/lib/popout/rescued-drafts'; +import { closePopoutWindow, isPopoutWindow } from '@/lib/popout/window-role'; +import { getStore, type StoreBackend } from '@/lib/store/client/api'; +import { listAccountRecords, rewrapAccountKey, StoreKeyLayerMissingError, type WrapLayer } from '@/lib/store/client/keys'; +import { OWNER_LOCK } from '@/lib/store/worker/protocol'; +import type { ComposerDraftData } from '@/components/email/email-composer'; +import { importLockKey, LOCK_LAYER, lockLayer, mainProof } from './crypto'; +import { watchIdle } from './idle'; +import { createPasskey, passkeyPrf, PasskeyNoPrfError } from './passkey'; +import { appLockRequired, effectiveTimeout, onLockPolicyChange } from './policy'; +import { scrubBrowserStorage } from './scrub'; +import { + APP_LOCK_CHANNEL, + APP_LOCK_HOLDER, + APP_LOCK_KEY, + isAppLockedNow, + postAppLockMessage, + readAppLockFile, + setLockedHere, + writeAppLockFile, + type AppLockFile, + type AppLockMessage, +} from './state'; +import { + addVaultPrf, + changeVaultPin, + createVault, + deleteVault, + openSealedDrafts, + openVaultWithPin, + openVaultWithPrf, + readVault, + removeVaultPrf, + sealDraft, +} from './vault'; + +export type AppLockPhase = + /** Off, or on and unlocked. */ + | 'open' + /** On, and this tab asks whether another tab holds the key. */ + | 'checking' + /** An organisation requires it and no PIN exists yet. */ + | 'setup' + /** On its way to the lock screen: saving, closing. */ + | 'locking' + | 'locked' + /** Locked, in a page that couldn't reload: the app is gone from the screen. */ + | 'locked-stay'; + +/** Long enough for a draft save, and for a send under way to end. The screen is covered by then. */ +const LOCK_FLUSH_MS = 15_000; +const STORE_CLOSE_MS = 5_000; +const OWNER_WAIT_MS = 5_000; +const HOLDER_WAIT_MS = 1_500; +const RELOAD_PROBE_MS = 2_000; +const DRAFTS_SAVED_KEY = 'bulwark-app-lock-drafts-saved'; + +let booted = false; +let phase: AppLockPhase = 'open'; +const listeners = new Set<() => void>(); +let hold: { promise: Promise; release: () => void } | null = null; + +/** Only ever in this tab's memory. Can't be read back out. */ +let heldKey: CryptoKey | null = null; +let releaseHolder: (() => void) | null = null; +let holderWatch: AbortController | null = null; +let stopIdle: (() => void) | null = null; + +function setPhase(next: AppLockPhase): void { + if (phase === next) return; + phase = next; + for (const listener of [...listeners]) listener(); +} + +export function getAppLockPhase(): AppLockPhase { + return phase; +} + +export function subscribeAppLockPhase(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function newHold(): { promise: Promise; release: () => void } { + let release = () => {}; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +/** While this is pending, the app under the gate doesn't hydrate. */ +export function appLockHold(): Promise | null { + return hold?.promise ?? null; +} + +function markDocument(locked: boolean): void { + if (typeof document === 'undefined') return; + if (locked) document.documentElement.setAttribute('data-app-locked', ''); + else document.documentElement.removeAttribute('data-app-locked'); +} + +function hasAccounts(): boolean { + try { + const parsed = JSON.parse(localStorage.getItem('account-registry') ?? 'null') as { state?: { accounts?: unknown[] } } | null; + return (parsed?.state?.accounts?.length ?? 0) > 0; + } catch { + return false; + } +} + +function setupNeeded(): boolean { + return !readAppLockFile().on && appLockRequired() && hasAccounts(); +} + +/** + * Decides this tab's start, synchronously, in the first client render. A tab that + * finds the lock on doesn't start the app until it knows a tab holds the key. + */ +export function bootAppLock(): void { + if (booted || typeof window === 'undefined' || IS_LITE) return; + booted = true; + setAppLockCheck(isAppLockedNow); + const file = readAppLockFile(); + if (file.on) { + setLockedHere(true); + hold = newHold(); + if (file.locked) { + phase = 'locked'; + tellDesktopLocked(); + } else { + phase = 'checking'; + void settleCheck(); + } + return; + } + markDocument(false); + if (setupNeeded()) { + hold = newHold(); + phase = 'setup'; + } +} + +async function holderAlive(): Promise { + if (!navigator.locks?.query) return false; + try { + const { held } = await navigator.locks.query(); + return (held ?? []).some((lock) => lock.name === APP_LOCK_HOLDER); + } catch { + return false; + } +} + +async function desktopSaysLocked(): Promise { + const member = getDesktopBridge()?.appLock; + if (!member) return false; + try { + return (await member.status()).locked; + } catch { + return true; + } +} + +async function settleCheck(): Promise { + if (phase !== 'checking') return; + if (await holderAlive() && !(await desktopSaysLocked())) { + openUp(); + return; + } + // Nobody holds the key: a crash, or the browser was closed without saying so. + writeAppLockFile({ ...readAppLockFile(), locked: true }); + tellDesktopLocked(); + tellServiceWorker('locked'); + setPhase('locked'); +} + +function tellDesktopLocked(): void { + getDesktopBridge()?.appLock?.lock(); +} + +/** The service worker shows push notifications with no page around. Kept across its restarts. */ +function tellServiceWorker(state: 'locked' | 'unlocked' | 'off'): void { + try { + const message = { type: 'app-lock', state }; + const worker = navigator.serviceWorker; + if (!worker) return; + if (worker.controller) worker.controller.postMessage(message); + else void worker.ready.then((registration) => registration.active?.postMessage(message)); + } catch { + // No service worker, no push notifications. + } +} + +function startIdle(): void { + stopIdle?.(); + const file = readAppLockFile(); + if (!file.on) { + stopIdle = null; + return; + } + stopIdle = watchIdle({ + timeoutMs: () => effectiveTimeout(readAppLockFile().timeoutMinutes).minutes * 60_000, + onIdle: () => lockEverywhere(), + }); +} + +/** + * A tab with no key watches for the one that has it to go. The request waits as long + * as the holder lives. Being granted means it's gone. + */ +function watchHolder(): void { + holderWatch?.abort(); + holderWatch = null; + if (!navigator.locks?.request || releaseHolder) return; + const watch = new AbortController(); + holderWatch = watch; + void navigator.locks.request(APP_LOCK_HOLDER, { signal: watch.signal }, () => { + if (heldKey) return keepHolder(); + if (readAppLockFile().on && !readAppLockFile().locked) lockEverywhere(); + return undefined; + }).catch(() => {}); +} + +function keepHolder(): Promise { + return new Promise((resolve) => { + releaseHolder = resolve; + }); +} + +async function becomeHolder(): Promise { + holderWatch?.abort(); + holderWatch = null; + if (releaseHolder || !navigator.locks?.request) return; + await new Promise((resolve) => { + const timer = setTimeout(resolve, HOLDER_WAIT_MS); + void navigator.locks.request(APP_LOCK_HOLDER, () => { + clearTimeout(timer); + resolve(); + return keepHolder(); + }).catch(() => resolve()); + }); +} + +function dropKey(): void { + heldKey = null; + releaseHolder?.(); + releaseHolder = null; +} + +/** The app starts, or goes on, in this tab. */ +function openUp(): void { + setLockedHere(false); + markDocument(false); + setPhase('open'); + startIdle(); + if (readAppLockFile().on && !heldKey) watchHolder(); + hold?.release(); + hold = null; +} + +// ---- Locking ---- + +/** Every window and tab. Safe to call at any time; it only ever makes things more locked. */ +export function lockEverywhere(): void { + const file = readAppLockFile(); + if (!file.on) return; + writeAppLockFile({ ...file, locked: true }); + postAppLockMessage({ type: 'lock' }); + void applyLockHere(); +} + +async function saveComposers(): Promise<{ closing: boolean; saved: number }> { + const snapshots = composeSnapshots(); + const flushed = await flushComposers(LOCK_FLUSH_MS); + const after = composeSnapshots(); + let closing = false; + let saved = 0; + const trouble = flushed.failed > 0 || flushed.timedOut; + for (const snapshot of after) { + const state = snapshot.state(); + const draft = snapshot.draft(); + if (trouble && state) { + // Not on the server. Sealed, it waits for the lock key. + await sealDraft(state).catch(() => {}); + if (snapshot.ownWindow) closing = true; + continue; + } + if (draft) saved += 1; + if (!snapshot.ownWindow) continue; + if (draft) { + // This window's address names no text of its own after a reload: it opens this draft. + window.history.replaceState(window.history.state, '', restoreUrlFor(draft)); + } else { + closing = true; + } + } + if (snapshots.length > 0 && saved > 0) { + try { + sessionStorage.setItem(DRAFTS_SAVED_KEY, '1'); + } catch { + // Only the notice goes. + } + } + return { closing, saved }; +} + +/** A flush under way stops at its next item (lib/offline/flush.ts). This waits for that item. */ +async function outboxAtRest(): Promise { + if (!navigator.locks?.query) return; + const until = Date.now() + LOCK_FLUSH_MS; + while (Date.now() < until) { + const { held } = await navigator.locks.query(); + if (!(held ?? []).some((lock) => lock.name?.startsWith('bulwark-offline-flush:'))) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +let reloadSeam: (() => void) | null = null; + +function reloadPage(): void { + if (reloadSeam) reloadSeam(); + else window.location.reload(); +} + +/** Tests only: jsdom can't reload. */ +export function setReloadForTests(run: (() => void) | null): void { + reloadSeam = run; +} + +async function pageReachable(): Promise { + try { + const response = await fetch(window.location.href, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(RELOAD_PROBE_MS) }); + return response.status < 500; + } catch { + return false; + } +} + +async function applyLockHere(): Promise { + if (phase === 'locking' || phase === 'locked' || phase === 'locked-stay') return; + const wasRunning = phase === 'open'; + setLockedHere(true); + markDocument(true); + hold ??= newHold(); + setPhase('locking'); + tellDesktopLocked(); + tellServiceWorker('locked'); + stopIdle?.(); + stopIdle = null; + holderWatch?.abort(); + holderWatch = null; + + let closing = false; + if (wasRunning) { + try { + ({ closing } = await saveComposers()); + await outboxAtRest(); + } catch { + // The lock goes on: what's on screen is covered either way. + } + await scrubBrowserStorage(); + } + await lockStore(); + dropKey(); + if (closing) closePopoutWindow(); + if (await pageReachable()) { + reloadPage(); + return; + } + // The server is out of reach and there's no copy of the page: a reload would land on the + // browser's error page, with no way to unlock. Off the screen now, out of memory when it's back. + setPhase('locked-stay'); + window.addEventListener('online', () => { + if (isAppLockedNow()) reloadPage(); + }); +} + +function storeBackend(): StoreBackend { + return getStore(); +} + +async function lockStore(): Promise { + try { + await storeBackend().lock?.(STORE_CLOSE_MS); + } catch { + // Its worker is stopped either way. + } +} + +// ---- Unlocking ---- + +async function holdAndOpen(raw: Uint8Array): Promise { + try { + const proof = await mainProof(raw); + const key = await importLockKey(raw); + await getDesktopBridge()?.appLock?.prove(proof).catch(() => false); + heldKey = key; + } finally { + raw.fill(0); + } + await becomeHolder(); + const key = heldKey!; + await addLayerWhereMissing(key); + await storeBackend().unlock?.(OWNER_WAIT_MS); + writeAppLockFile({ ...readAppLockFile(), on: true, locked: false }); + postAppLockMessage({ type: 'unlocked' }); + tellServiceWorker('unlocked'); + openUp(); + await deliverSealedDrafts(key); +} + +async function deliverSealedDrafts(key: CryptoKey): Promise { + try { + for (const state of await openSealedDrafts(key)) { + if (isPopoutWindow()) postAppLockMessage({ type: 'unsealed-draft', state }); + else keepRescuedDraft(state as ComposerDraftData); + } + } catch { + // They stay sealed for the next unlock. + } +} + +export async function unlockWithPin(pin: string): Promise { + await holdAndOpen(await openVaultWithPin(pin)); +} + +export async function unlockWithPasskey(): Promise { + const vault = await readVault(); + if (!vault?.prf) throw new PasskeyNoPrfError(); + const output = await passkeyPrf(vault.prf.credentialId, vault.prf.salt); + if (!output) throw new PasskeyNoPrfError(); + await holdAndOpen(await openVaultWithPrf(output)); +} + +/** Windows Hello or Touch ID. Main asks, and only hands over the key after a yes. */ +export async function unlockWithOs(): Promise { + const member = getDesktopBridge()?.appLock; + if (!member) return false; + const answer = await member.osUnlock(); + if (!answer.ok) return false; + await holdAndOpen(new Uint8Array(answer.key)); + return true; +} + +// ---- The account keys ---- + +/** A store record made when the lock was off, or before a crash: it gets the layer now. */ +async function addLayerWhereMissing(key: CryptoKey): Promise { + const layer = lockLayer(key); + for (const record of await listAccountRecords().catch(() => [])) { + if (!record.layers.includes(LOCK_LAYER)) await rewrapAccountKey(record.account, [layer], [layer]); + } +} + +async function removeLayerEverywhere(key: CryptoKey): Promise { + const layer = lockLayer(key); + for (const record of await listAccountRecords().catch(() => [])) { + if (record.layers.includes(LOCK_LAYER)) await rewrapAccountKey(record.account, [layer], []); + } +} + +/** What the store's key handling uses. Throws while this tab has no key, so nothing opens or gets written unwrapped. */ +export function appLockKeyLayers(): readonly WrapLayer[] { + if (!readAppLockFile().on) return []; + if (!heldKey) throw new StoreKeyLayerMissingError(LOCK_LAYER); + return [lockLayer(heldKey)]; +} + +/** This tab inherited the store and has no key for it. */ +export function onStoreKeyMissing(): void { + lockEverywhere(); +} + +async function ownerIsElsewhere(): Promise { + if (!navigator.locks?.query) return false; + const { held } = await navigator.locks.query(); + return (held ?? []).some((lock) => lock.name === OWNER_LOCK) && !storeBackend().ownsStoreHere?.(); +} + +// ---- Settings ---- + +export async function enableAppLock(pin: string): Promise { + const raw = await createVault(pin); + const proof = await mainProof(raw); + const member = getDesktopBridge()?.appLock; + if (member && !(await member.enroll(proof))) { + // Main still knows an earlier lock this profile lost. It asks the user natively. + if (!(await member.forget()) || !(await member.enroll(proof))) { + raw.fill(0); + await deleteVault(); + throw new Error('The desktop app refused the lock'); + } + } + const key = await importLockKey(raw); + raw.fill(0); + heldKey = key; + await becomeHolder(); + await addLayerWhereMissing(key); + const file: AppLockFile = { ...readAppLockFile(), on: true, locked: false }; + writeAppLockFile(file); + try { + localStorage.removeItem('email-snapshot'); + } catch { + // Storage blocked: there's no snapshot either. + } + postAppLockMessage({ type: 'enabled' }); + tellServiceWorker('unlocked'); + openUp(); + // Another tab has the store open and no key for what comes next. Everyone starts over. + if (await ownerIsElsewhere()) lockEverywhere(); +} + +/** Off again. The PIN proves it's the owner of the lock, in this tab or any other. */ +export async function disableAppLock(pin: string): Promise { + if (appLockRequired()) throw new Error('The organisation requires the app lock'); + const raw = await openVaultWithPin(pin); + let key: CryptoKey; + try { + const proof = await mainProof(raw); + await getDesktopBridge()?.appLock?.disable(proof); + key = await importLockKey(raw); + } finally { + raw.fill(0); + } + await removeLayerEverywhere(key); + await deleteVault(); + writeAppLockFile({ on: false, locked: false }); + postAppLockMessage({ type: 'off' }); + tellServiceWorker('off'); + afterOff(); +} + +function afterOff(): void { + stopIdle?.(); + stopIdle = null; + holderWatch?.abort(); + holderWatch = null; + dropKey(); + setLockedHere(false); + markDocument(false); + setPhase('open'); + hold?.release(); + hold = null; +} + +export function changeAppLockPin(currentPin: string, newPin: string): Promise { + return changeVaultPin(currentPin, newPin); +} + +export function setAppLockTimeout(minutes: number): void { + writeAppLockFile({ ...readAppLockFile(), timeoutMinutes: minutes }); +} + +export async function addPasskey(pin: string, appName: string): Promise { + const passkey = await createPasskey(appName); + const raw = await openVaultWithPin(pin); + try { + await addVaultPrf(raw, passkey.credentialId, passkey.salt, passkey.prfOutput); + } finally { + raw.fill(0); + } +} + +export function removePasskey(): Promise { + return removeVaultPrf(); +} + +export async function hasPasskey(): Promise { + return Boolean((await readVault())?.prf); +} + +export async function addOsUnlock(pin: string): Promise { + const member = getDesktopBridge()?.appLock; + if (!member) return false; + const raw = await openVaultWithPin(pin); + try { + return await member.osEnroll(await mainProof(raw), raw); + } finally { + raw.fill(0); + } +} + +/** + * The way out when the PIN is gone: all local data, and every login on this device. The + * server keeps everything except what waited in the outbox here. + */ +export async function forgetEverything(): Promise { + const member = getDesktopBridge()?.appLock; + // Native: a page's word alone can't lift the desktop's lock. + if (member && !(await member.forget())) return false; + const { useAuthStore } = await import('@/stores/auth-store'); + await Promise.race([ + useAuthStore.getState().logoutAll({ endProviderSession: false }), + new Promise((resolve) => setTimeout(resolve, STORE_CLOSE_MS)), + ]).catch(() => {}); + await deleteVault(); + writeAppLockFile({ on: false, locked: false }); + postAppLockMessage({ type: 'off' }); + tellServiceWorker('off'); + afterOff(); + window.location.replace(new URL(process.env.NEXT_PUBLIC_BASE_PATH || '/', window.location.origin).href); + return true; +} + +/** A saved draft's notice, once, after the lock that saved it. */ +export function takeDraftsSavedNotice(): boolean { + try { + if (sessionStorage.getItem(DRAFTS_SAVED_KEY) === null) return false; + sessionStorage.removeItem(DRAFTS_SAVED_KEY); + return true; + } catch { + return false; + } +} + +// ---- The tab's life ---- + +function onMessage(message: AppLockMessage): void { + switch (message.type) { + case 'lock': + void applyLockHere(); + break; + case 'unlocked': + if (phase === 'locked' || phase === 'checking' || phase === 'locked-stay') { + void desktopSaysLocked().then((locked) => { + if (!locked) openUp(); + }); + } + break; + case 'enabled': + if (phase === 'setup') { + openUp(); + break; + } + startIdle(); + if (!heldKey) watchHolder(); + break; + case 'off': + afterOff(); + break; + case 'main-ping': + if (!isPopoutWindow()) postAppLockMessage({ type: 'main-pong' }); + break; + case 'focus-main': + if (!isPopoutWindow()) window.focus(); + break; + case 'unsealed-draft': + if (!isPopoutWindow()) keepRescuedDraft(message.state as ComposerDraftData); + break; + } +} + +/** Runs for as long as the tab does, above the part of the page that the lock holds back. */ +export function startAppLock(): () => void { + if (IS_LITE || typeof window === 'undefined') return () => {}; + bootAppLock(); + let channel: BroadcastChannel | null = null; + if (typeof BroadcastChannel !== 'undefined') { + channel = new BroadcastChannel(APP_LOCK_CHANNEL); + channel.onmessage = (event: MessageEvent) => onMessage(event.data); + } + const onStorage = (event: StorageEvent) => { + if (event.key !== APP_LOCK_KEY) return; + const file = readAppLockFile(); + if (file.on && file.locked && phase === 'open') void applyLockHere(); + }; + window.addEventListener('storage', onStorage); + const stopDesktop = getDesktopBridge()?.appLock?.onLockRequest(() => lockEverywhere()); + const reevaluate = () => { + if (phase === 'open' && setupNeeded()) setPhase('setup'); + }; + const stopPolicy = onLockPolicyChange(reevaluate); + if (phase === 'open' && readAppLockFile().on) { + startIdle(); + if (!heldKey) watchHolder(); + } + tellServiceWorker(readAppLockFile().on ? (isAppLockedNow() ? 'locked' : 'unlocked') : 'off'); + return () => { + channel?.close(); + window.removeEventListener('storage', onStorage); + stopDesktop?.(); + stopPolicy(); + stopIdle?.(); + stopIdle = null; + }; +} + +/** An account appeared in a tab that started with none: a required lock is due now. */ +export function recheckAppLockSetup(): void { + if (phase === 'open' && setupNeeded()) setPhase('setup'); +} + +/** Tests only. */ +export function resetAppLockForTests(): void { + booted = false; + phase = 'open'; + hold = null; + heldKey = null; + releaseHolder = null; + holderWatch = null; + stopIdle?.(); + stopIdle = null; + listeners.clear(); + setLockedHere(false); +} diff --git a/lib/app-lock/crypto.ts b/lib/app-lock/crypto.ts new file mode 100644 index 000000000..3bc79134a --- /dev/null +++ b/lib/app-lock/crypto.ts @@ -0,0 +1,115 @@ +/** + * The keys behind app lock, WebCrypto only. + * + * One random lock key wraps every account's database key (a layer in + * lib/store/client/keys.ts). The lock key itself is only ever stored wrapped: once + * under the PIN, and once for each other way in (a passkey's PRF, the desktop app's + * OS storage). So a new account needs no PIN, and a changed PIN touches one record. + */ + +import type { WrapLayer } from '@/lib/store/client/keys'; + +export const LOCK_LAYER = 'applock'; +/** OWASP's floor for PBKDF2-HMAC-SHA256. A wrong guess costs this much, online or off. */ +export const PBKDF2_ITERATIONS = 600_000; +export const MIN_PIN_LENGTH = 6; + +const AES = { name: 'AES-GCM', length: 256 } as const; +const PRF_INFO = new TextEncoder().encode('bulwark-app-lock-prf-v1'); +const PROOF_INFO = new TextEncoder().encode('bulwark-app-lock-main-proof-v1'); + +export interface WrappedBytes { + iv: Uint8Array; + data: Uint8Array; +} + +function buffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +export function randomBytes(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)); +} + +/** The same PIN typed on another keyboard or OS gives the same bytes. */ +export function pinBytes(pin: string): Uint8Array { + return new TextEncoder().encode(pin.normalize('NFKC')); +} + +export function pinLongEnough(pin: string): boolean { + return [...pin.normalize('NFKC')].length >= MIN_PIN_LENGTH; +} + +/** Slow on purpose. Not extractable: it only ever wraps the lock key. */ +export async function keyFromPin(pin: string, salt: Uint8Array, iterations: number): Promise { + const base = await crypto.subtle.importKey('raw', buffer(pinBytes(pin)), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', hash: 'SHA-256', salt: buffer(salt), iterations }, + base, + AES, + false, + ['encrypt', 'decrypt'], + ); +} + +/** A key from what an authenticator's PRF gave: 32 bytes that only it can make again. */ +export async function keyFromPrf(output: ArrayBuffer | Uint8Array): Promise { + const bytes = output instanceof Uint8Array ? output : new Uint8Array(output); + const base = await crypto.subtle.importKey('raw', buffer(bytes), 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new ArrayBuffer(0), info: PRF_INFO }, + base, + AES, + false, + ['encrypt', 'decrypt'], + ); +} + +export async function seal(key: CryptoKey, plain: Uint8Array): Promise { + const iv = randomBytes(12); + const data = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buffer(iv) }, key, buffer(plain))); + return { iv, data }; +} + +/** Throws when the key is the wrong one: that's how a wrong PIN shows. */ +export async function open(key: CryptoKey, wrapped: WrappedBytes): Promise { + return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: buffer(wrapped.iv) }, key, buffer(wrapped.data))); +} + +/** + * What shows the desktop app's main process that someone really unlocked: only the lock + * key's bytes give it, and they exist only just after a real unlock. Main keeps a hash. + */ +export async function mainProof(raw: Uint8Array): Promise { + const base = await crypto.subtle.importKey('raw', buffer(raw), 'HKDF', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: new ArrayBuffer(0), info: PROOF_INFO }, + base, + 256, + ); + return new Uint8Array(bits); +} + +/** The lock key for use. It can wrap and unwrap, and can't be read back out. */ +export function importLockKey(raw: Uint8Array): Promise { + return crypto.subtle.importKey('raw', buffer(raw), AES, false, ['encrypt', 'decrypt']); +} + +/** The layer around each account's database key. The IV goes in front of the bytes. */ +export function lockLayer(key: CryptoKey): WrapLayer { + return { + name: LOCK_LAYER, + async wrap(data) { + const iv = randomBytes(12); + const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buffer(iv) }, key, buffer(data))); + const out = new Uint8Array(iv.length + sealed.length); + out.set(iv); + out.set(sealed, iv.length); + return out; + }, + async unwrap(data) { + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: buffer(data.slice(0, 12)) }, key, buffer(data.slice(12))); + return new Uint8Array(plain); + }, + }; +} diff --git a/lib/app-lock/idle.ts b/lib/app-lock/idle.ts new file mode 100644 index 000000000..9a622b5c8 --- /dev/null +++ b/lib/app-lock/idle.ts @@ -0,0 +1,85 @@ +/** + * Time without use, counted across all windows and tabs: every one of them writes + * to the same localStorage key and reads from it. Clocks jump over sleep, which is + * what's wanted: a laptop opened after the timeout locks at once. + */ + +import { APP_LOCK_ACTIVITY_KEY } from './state'; + +const WRITE_EVERY_MS = 5_000; +const CHECK_EVERY_MS = 10_000; +const EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'scroll'] as const; + +export function lastActivity(): number { + try { + const at = Number(localStorage.getItem(APP_LOCK_ACTIVITY_KEY)); + return Number.isFinite(at) ? at : 0; + } catch { + return 0; + } +} + +export function noteActivity(now = Date.now()): void { + try { + localStorage.setItem(APP_LOCK_ACTIVITY_KEY, String(now)); + } catch { + // This tab's own timer then runs from when it started. + } +} + +export interface IdleWatchOptions { + timeoutMs: () => number; + onIdle: () => void; + now?: () => number; +} + +/** + * Calls `onIdle` once the time has passed. A touch after it has passed locks rather + * than counts: coming back to the machine must not undo the timeout. Returns the stop. + */ +export function watchIdle(options: IdleWatchOptions): () => void { + const now = options.now ?? Date.now; + let last = now(); + noteActivity(last); + let written = last; + let stopped = false; + + const expired = (at: number) => at - Math.max(last, lastActivity()) >= options.timeoutMs(); + const fire = () => { + if (stopped) return; + stop(); + options.onIdle(); + }; + const check = () => { + if (expired(now())) fire(); + }; + const onUse = () => { + const at = now(); + if (expired(at)) { + fire(); + return; + } + last = at; + if (at - written >= WRITE_EVERY_MS) { + written = at; + noteActivity(at); + } + }; + const onVisible = () => { + if (document.visibilityState === 'visible') check(); + }; + + for (const event of EVENTS) window.addEventListener(event, onUse, { capture: true, passive: true }); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('focus', check); + const timer = setInterval(check, CHECK_EVERY_MS); + + function stop() { + stopped = true; + clearInterval(timer); + for (const event of EVENTS) window.removeEventListener(event, onUse, { capture: true }); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('focus', check); + } + return stop; +} diff --git a/lib/app-lock/passkey.ts b/lib/app-lock/passkey.ts new file mode 100644 index 000000000..535ab2170 --- /dev/null +++ b/lib/app-lock/passkey.ts @@ -0,0 +1,81 @@ +/** + * WebAuthn with PRF, to wrap the lock key. There is no server in this: nothing checks + * the assertion, and the challenge is only there because the API asks for one. What + * counts is the 32 bytes the authenticator derives, without which the wrap can't open. + * + * A browser can't say ahead of time whether a new passkey has PRF, so it is tried right + * after creating it. The PIN stays either way. + */ + +import { randomBytes } from './crypto'; + +export class PasskeyNoPrfError extends Error { + constructor() { + super('This passkey has no PRF'); + this.name = 'PasskeyNoPrfError'; + } +} + +interface PrfResults { + enabled?: boolean; + results?: { first?: ArrayBuffer }; +} + +function buffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +export function passkeysPossible(): boolean { + return typeof window !== 'undefined' && window.isSecureContext && typeof window.PublicKeyCredential === 'function' && !!navigator.credentials; +} + +function prfOf(credential: PublicKeyCredential): PrfResults | undefined { + return (credential.getClientExtensionResults() as { prf?: PrfResults }).prf; +} + +/** The authenticator's answer for `salt`. Null when this one has no PRF. */ +export async function passkeyPrf(credentialId: Uint8Array, salt: Uint8Array): Promise { + const credential = await navigator.credentials.get({ + publicKey: { + challenge: buffer(randomBytes(32)), + rpId: location.hostname, + allowCredentials: [{ type: 'public-key', id: buffer(credentialId) }], + userVerification: 'required', + timeout: 120_000, + extensions: { prf: { eval: { first: buffer(salt) } } } as AuthenticationExtensionsClientInputs, + }, + }) as PublicKeyCredential | null; + if (!credential) return null; + return prfOf(credential)?.results?.first ?? null; +} + +export interface NewPasskey { + credentialId: Uint8Array; + salt: Uint8Array; + prfOutput: ArrayBuffer; +} + +/** Throws PasskeyNoPrfError when the new passkey turns out to have no PRF. */ +export async function createPasskey(appName: string): Promise { + const salt = randomBytes(32); + const credential = await navigator.credentials.create({ + publicKey: { + rp: { name: appName, id: location.hostname }, + user: { id: buffer(randomBytes(16)), name: appName, displayName: appName }, + challenge: buffer(randomBytes(32)), + pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], + authenticatorSelection: { userVerification: 'required', residentKey: 'discouraged' }, + timeout: 120_000, + // Some authenticators only say they have PRF when asked with an input. + extensions: { prf: { eval: { first: buffer(salt) } } } as AuthenticationExtensionsClientInputs, + }, + }) as PublicKeyCredential | null; + if (!credential) throw new PasskeyNoPrfError(); + const credentialId = new Uint8Array(credential.rawId); + const made = prfOf(credential); + if (made?.enabled === false) throw new PasskeyNoPrfError(); + // Windows Hello and others only give the bytes on `get`, so ask once to be sure. + const prfOutput = made?.results?.first ?? await passkeyPrf(credentialId, salt); + if (!prfOutput) throw new PasskeyNoPrfError(); + return { credentialId, salt, prfOutput }; +} diff --git a/lib/app-lock/policy.ts b/lib/app-lock/policy.ts new file mode 100644 index 000000000..718e2b27a --- /dev/null +++ b/lib/app-lock/policy.ts @@ -0,0 +1,64 @@ +/** + * The desktop app's OS policies for the lock, read through the bridge. In a browser + * there's no bridge and nothing is required. The bridge's `policy` member comes from + * another package, so it is feature detected, as lib/offline/scope.ts does. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { DEFAULT_TIMEOUT_MINUTES } from './state'; + +interface PolicyEntry { + value: T; + locked: boolean; + source: string; +} + +interface LockPolicies { + AppLockRequired?: PolicyEntry; + AppLockTimeoutMinutes?: PolicyEntry; +} + +interface PolicyBridge { + get?: () => LockPolicies | null | undefined; + onChange?: (listener: (snapshot: LockPolicies) => void) => () => void; +} + +function policyBridge(): PolicyBridge | undefined { + return (getDesktopBridge() as { policy?: PolicyBridge } | null)?.policy; +} + +function read(): LockPolicies { + try { + return policyBridge()?.get?.() ?? {}; + } catch { + return {}; + } +} + +/** Only a forced value requires it: a recommended one can't choose a PIN for anyone. */ +export function appLockRequired(): boolean { + const entry = read().AppLockRequired; + return entry?.locked === true && entry.value === true; +} + +export interface EffectiveTimeout { + minutes: number; + /** Set by the organisation: the user's choice doesn't apply. */ + locked: boolean; +} + +/** Forced, then the user's choice, then recommended, then the default. */ +export function effectiveTimeout(choice: number | undefined): EffectiveTimeout { + const entry = read().AppLockTimeoutMinutes; + const value = typeof entry?.value === 'number' && entry.value > 0 ? entry.value : null; + if (entry?.locked && value !== null) return { minutes: value, locked: true }; + return { minutes: choice ?? value ?? DEFAULT_TIMEOUT_MINUTES, locked: false }; +} + +export function onLockPolicyChange(listener: () => void): () => void { + try { + return policyBridge()?.onChange?.(() => listener()) ?? (() => {}); + } catch { + return () => {}; + } +} diff --git a/lib/app-lock/scrub.ts b/lib/app-lock/scrub.ts new file mode 100644 index 000000000..1e6dfa26d --- /dev/null +++ b/lib/app-lock/scrub.ts @@ -0,0 +1,44 @@ +/** + * What the app keeps in plain browser storage that names mail. A lock must not leave + * it readable, so it goes before the lock screen comes up. Text that could not be saved + * anywhere else is sealed first (see lib/app-lock/vault.ts). + */ + +import { sealDraft } from './vault'; + +/** Subjects and senders: a list snapshot for a fast start, and the Pro tabs' names. */ +const LOCAL_KEYS = ['email-snapshot', 'pro-tabs']; +const SESSION_KEYS = ['bulwark-offline-lost']; +const SESSION_PREFIXES = ['bulwark-popout-compose:']; +const RESCUED_KEY = 'bulwark-rescued-drafts'; + +function removeSessionKeys(): void { + for (const key of SESSION_KEYS) sessionStorage.removeItem(key); + for (let index = sessionStorage.length - 1; index >= 0; index -= 1) { + const key = sessionStorage.key(index); + if (key && SESSION_PREFIXES.some((prefix) => key.startsWith(prefix))) sessionStorage.removeItem(key); + } +} + +export async function scrubBrowserStorage(): Promise { + try { + const raw = sessionStorage.getItem(RESCUED_KEY); + const rescued: unknown = raw ? JSON.parse(raw) : []; + if (Array.isArray(rescued)) for (const draft of rescued) await sealDraft(draft); + sessionStorage.removeItem(RESCUED_KEY); + } catch { + // Nothing readable to keep. + } + try { + removeSessionKeys(); + } catch { + // Storage blocked: nothing was kept there either. + } + for (const key of LOCAL_KEYS) { + try { + localStorage.removeItem(key); + } catch { + // As above. + } + } +} diff --git a/lib/app-lock/sign-out.ts b/lib/app-lock/sign-out.ts new file mode 100644 index 000000000..881338326 --- /dev/null +++ b/lib/app-lock/sign-out.ts @@ -0,0 +1,15 @@ +/** + * The sign-out's side of the lock. Apart from controller.ts because lib/sign-out-cleanup.ts + * is in every page's bundle and is imported by what the controller imports. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { writeAppLockFile } from './state'; +import { deleteVault } from './vault'; + +/** Nobody is left to lock for. Main only lets go without asking while it's unlocked. */ +export function forgetAppLockAtSignOut(): void { + void deleteVault(); + writeAppLockFile({ on: false, locked: false }); + void getDesktopBridge()?.appLock?.forget().catch(() => false); +} diff --git a/lib/app-lock/state.ts b/lib/app-lock/state.ts new file mode 100644 index 000000000..f662838ea --- /dev/null +++ b/lib/app-lock/state.ts @@ -0,0 +1,103 @@ +/** + * App lock's plain facts, and how tabs tell each other. No secrets and no mail: + * whether the lock is on, whether it is locked now, and the timeout. This stays + * in localStorage because the first frame has to know, before anything async can + * run. Someone who can edit it can't unlock anything: the keys need the PIN. + * + * Imports nothing, so anything may import it: lib/popout/channel.ts, the store's + * flush and the auth store all ask here. + */ + +export const APP_LOCK_KEY = 'bulwark-app-lock'; +/** When someone last used a window or tab. Shared by all of them. */ +export const APP_LOCK_ACTIVITY_KEY = 'bulwark-app-lock-activity'; +export const APP_LOCK_CHANNEL = 'bulwark:app-lock'; +/** Web Lock held by the tab that has the lock key, for as long as it lives. */ +export const APP_LOCK_HOLDER = 'bulwark-app-lock-holder'; + +export const DEFAULT_TIMEOUT_MINUTES = 10; +export const TIMEOUT_CHOICES = [1, 5, 10, 15, 30, 60, 240] as const; + +export interface AppLockFile { + on: boolean; + locked: boolean; + /** The user's choice. Absent until they make one. */ + timeoutMinutes?: number; +} + +const OFF: AppLockFile = { on: false, locked: false }; + +export function readAppLockFile(): AppLockFile { + try { + const parsed = JSON.parse(localStorage.getItem(APP_LOCK_KEY) ?? 'null') as Partial | null; + if (!parsed || typeof parsed !== 'object' || parsed.on !== true) return OFF; + return { + on: true, + // Anything but a clear "no" counts as locked. + locked: parsed.locked !== false, + ...(typeof parsed.timeoutMinutes === 'number' ? { timeoutMinutes: parsed.timeoutMinutes } : {}), + }; + } catch { + return OFF; + } +} + +export function writeAppLockFile(next: AppLockFile): void { + try { + if (next.on) localStorage.setItem(APP_LOCK_KEY, JSON.stringify(next)); + else localStorage.removeItem(APP_LOCK_KEY); + } catch { + // Storage blocked: this tab's memory below still says what it knows. + } +} + +/** Set while this tab is on its way to locked, or shows the lock screen. */ +let lockedHere = false; + +export function setLockedHere(locked: boolean): void { + lockedHere = locked; +} + +/** Nothing with mail in it may show, go out or be read now. */ +export function isAppLockedNow(): boolean { + if (lockedHere) return true; + if (typeof window === 'undefined') return false; + const file = readAppLockFile(); + return file.on && file.locked; +} + +export function isAppLockOn(): boolean { + return typeof window !== 'undefined' && readAppLockFile().on; +} + +export type AppLockMessage = + | { type: 'lock' } + | { type: 'unlocked' } + /** The lock was switched on in another tab. */ + | { type: 'enabled' } + | { type: 'off' } + /** Asks whether a main window is there: a pop-out sends people to it to unlock. */ + | { type: 'main-ping' } + | { type: 'main-pong' } + | { type: 'focus-main' } + /** A draft a locking pop-out couldn't save, unsealed: a main window keeps it. */ + | { type: 'unsealed-draft'; state: unknown }; + +export function postAppLockMessage(message: AppLockMessage): void { + if (typeof BroadcastChannel === 'undefined') return; + try { + const channel = new BroadcastChannel(APP_LOCK_CHANNEL); + channel.postMessage(message); + channel.close(); + } catch { + // The `storage` event on the file still reaches the other tabs. + } +} + +/** Runs in the head script, before the first paint. Self-contained: it is stringified. */ +export function appLockBootScript(): string { + return `try { + var appLock = JSON.parse(localStorage.getItem(${JSON.stringify(APP_LOCK_KEY)}) || 'null'); + if (appLock && appLock.on === true) document.documentElement.setAttribute('data-app-locked', ''); + } catch (e) {}`; +} diff --git a/lib/app-lock/vault.ts b/lib/app-lock/vault.ts new file mode 100644 index 000000000..92a751347 --- /dev/null +++ b/lib/app-lock/vault.ts @@ -0,0 +1,262 @@ +/** + * What app lock keeps on disk, in its own IndexedDB database: the lock key wrapped + * under the PIN and the other ways in, the count of wrong PINs, and drafts sealed + * on their way to the lock screen. + * + * The count is written before a PIN is checked, so reloading during the check or + * after a wrong one gains nothing. This slows guessing through the app. Someone + * with the disk can skip it, and then only the PIN's length and PBKDF2 stand in the + * way, which is why a PIN has a minimum length and letters are allowed. + */ + +import { sealFor, createSessionOpener, type SealedAnswer } from '@/lib/popout/sealed'; +import { keyFromPin, keyFromPrf, open, PBKDF2_ITERATIONS, randomBytes, seal, type WrappedBytes } from './crypto'; + +export const VAULT_DB = 'bulwark-app-lock'; +const DB_VERSION = 1; +const RECORDS = 'records'; +const SEALED = 'sealed'; +const VAULT_ID = 'vault'; +const ATTEMPTS_ID = 'attempts'; + +export interface PinWrap extends WrappedBytes { + salt: Uint8Array; + iterations: number; +} + +export interface PrfWrap extends WrappedBytes { + credentialId: Uint8Array; + /** What the authenticator's PRF is asked about. */ + salt: Uint8Array; +} + +export interface VaultRecord { + id: typeof VAULT_ID; + pin: PinWrap; + prf?: PrfWrap; + /** For sealing drafts in tabs with no key: anyone may seal, only the lock key opens. */ + sealing: { publicKey: JsonWebKey; privateKey: WrappedBytes }; +} + +export interface AttemptsRecord { + id: typeof ATTEMPTS_ID; + failures: number; + blockedUntil: number; +} + +function promisify(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function done(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + tx.onerror = () => reject(tx.error); + }); +} + +function openDb(): Promise { + const request = indexedDB.open(VAULT_DB, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(RECORDS)) db.createObjectStore(RECORDS, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(SEALED)) db.createObjectStore(SEALED, { keyPath: 'id', autoIncrement: true }); + }; + return promisify(request); +} + +async function withDb(run: (db: IDBDatabase) => Promise): Promise { + const db = await openDb(); + try { + return await run(db); + } finally { + db.close(); + } +} + +async function readRecord(id: string): Promise { + return withDb(async (db) => (await promisify(db.transaction(RECORDS, 'readonly').objectStore(RECORDS).get(id)) as T | undefined) ?? null); +} + +async function writeRecord(record: VaultRecord | AttemptsRecord): Promise { + await withDb(async (db) => { + const tx = db.transaction(RECORDS, 'readwrite'); + tx.objectStore(RECORDS).put(record); + await done(tx); + }); +} + +export function readVault(): Promise { + return readRecord(VAULT_ID); +} + +async function pinWrapOf(pin: string, raw: Uint8Array): Promise { + const salt = randomBytes(32); + const key = await keyFromPin(pin, salt, PBKDF2_ITERATIONS); + return { salt, iterations: PBKDF2_ITERATIONS, ...(await seal(key, raw)) }; +} + +/** A new lock key under this PIN. The caller holds the bytes only long enough to use them. */ +export async function createVault(pin: string): Promise { + const raw = randomBytes(32); + const lockKey = await crypto.subtle.importKey('raw', raw.slice().buffer as ArrayBuffer, 'AES-GCM', false, ['encrypt']); + const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']); + const privateJwk = new TextEncoder().encode(JSON.stringify(await crypto.subtle.exportKey('jwk', pair.privateKey))); + const record: VaultRecord = { + id: VAULT_ID, + pin: await pinWrapOf(pin, raw), + sealing: { + publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), + privateKey: await seal(lockKey, privateJwk), + }, + }; + await writeRecord(record); + await writeRecord({ id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 }); + return raw; +} + +/** Wrong PINs that cost nothing. A typo or two shouldn't make anyone wait. */ +export const FREE_FAILURES = 4; +const FIRST_WAIT_MS = 30_000; +/** A long wait, and no wipe: forgetting a PIN must not be the way mail gets deleted. */ +export const LONGEST_WAIT_MS = 60 * 60_000; + +/** How long after this many wrong tries the next is allowed. */ +export function waitAfter(failures: number): number { + if (failures <= FREE_FAILURES) return 0; + return Math.min(FIRST_WAIT_MS * 2 ** (failures - FREE_FAILURES - 1), LONGEST_WAIT_MS); +} + +export class PinBlockedError extends Error { + constructor(readonly until: number) { + super('Too many wrong PINs'); + this.name = 'PinBlockedError'; + } +} + +export class WrongPinError extends Error { + constructor() { + super('Wrong PIN'); + this.name = 'WrongPinError'; + } +} + +export async function pinBlockedUntil(now = Date.now()): Promise { + const attempts = await readRecord(ATTEMPTS_ID); + return attempts && attempts.blockedUntil > now ? attempts.blockedUntil : 0; +} + +/** The lock key's bytes for the right PIN. Counts first, checks after. */ +export async function openVaultWithPin(pin: string, now = Date.now()): Promise { + const vault = await readVault(); + if (!vault) throw new WrongPinError(); + const attempts = (await readRecord(ATTEMPTS_ID)) ?? { id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 }; + if (attempts.blockedUntil > now) throw new PinBlockedError(attempts.blockedUntil); + const failures = attempts.failures + 1; + await writeRecord({ id: ATTEMPTS_ID, failures, blockedUntil: now + waitAfter(failures) }); + try { + const key = await keyFromPin(pin, vault.pin.salt, vault.pin.iterations); + const raw = await open(key, vault.pin); + await writeRecord({ id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 }); + return raw; + } catch { + throw new WrongPinError(); + } +} + +/** Only ever the PIN's own wrap: the lock key and everything under it stay. */ +export async function changeVaultPin(currentPin: string, newPin: string): Promise { + const raw = await openVaultWithPin(currentPin); + try { + const vault = await readVault(); + if (!vault) throw new WrongPinError(); + await writeRecord({ ...vault, pin: await pinWrapOf(newPin, raw) }); + } finally { + raw.fill(0); + } +} + +/** The passkey's way in. `prfOutput` is what the authenticator gave for `salt`. */ +export async function addVaultPrf(raw: Uint8Array, credentialId: Uint8Array, salt: Uint8Array, prfOutput: ArrayBuffer | Uint8Array): Promise { + const vault = await readVault(); + if (!vault) throw new Error('The app lock is not set up'); + await writeRecord({ ...vault, prf: { credentialId, salt, ...(await seal(await keyFromPrf(prfOutput), raw)) } }); +} + +export async function removeVaultPrf(): Promise { + const vault = await readVault(); + if (!vault?.prf) return; + const { prf: _gone, ...rest } = vault; + await writeRecord(rest); +} + +export async function openVaultWithPrf(prfOutput: ArrayBuffer | Uint8Array): Promise { + const vault = await readVault(); + if (!vault?.prf) throw new WrongPinError(); + return open(await keyFromPrf(prfOutput), vault.prf); +} + +/** Deleted at sign-out and when the lock goes off. The store's keys can't depend on it then. */ +export async function deleteVault(): Promise { + await new Promise((resolve) => { + try { + const request = indexedDB.deleteDatabase(VAULT_DB); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + request.onblocked = () => resolve(); + } catch { + resolve(); + } + }); +} + +// ---- Drafts a locking window couldn't save ---- + +interface SealedRow { + id?: number; + sealed: SealedAnswer; +} + +/** Works in any tab, with no key: the public half is enough to seal. */ +export async function sealDraft(state: unknown): Promise { + const vault = await readVault(); + if (!vault) return false; + const sealed = await sealFor(vault.sealing.publicKey, state); + await withDb(async (db) => { + const tx = db.transaction(SEALED, 'readwrite'); + tx.objectStore(SEALED).add({ sealed } satisfies SealedRow); + await done(tx); + }); + return true; +} + +/** Only with the lock key's bytes. Takes the drafts off the list. */ +export async function openSealedDrafts(lockKey: CryptoKey): Promise { + const vault = await readVault(); + if (!vault) return []; + const rows = await withDb((db) => promisify(db.transaction(SEALED, 'readonly').objectStore(SEALED).getAll())) as SealedRow[]; + if (rows.length === 0) return []; + const jwk = JSON.parse(new TextDecoder().decode(await open(lockKey, vault.sealing.privateKey))) as JsonWebKey; + const opener = await createSessionOpener(jwk); + const out: unknown[] = []; + for (const row of rows) { + try { + out.push(await opener.open(row.sealed)); + } catch { + // Sealed under an older key: nothing can read it, and it must not block the rest. + } + } + await withDb(async (db) => { + const tx = db.transaction(SEALED, 'readwrite'); + tx.objectStore(SEALED).clear(); + await done(tx); + }); + return out; +} + +export { keyFromPrf }; +export type { SealedAnswer }; diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts index b0215caab..97dc7ab6f 100644 --- a/lib/compose-flush-registry.ts +++ b/lib/compose-flush-registry.ts @@ -10,7 +10,9 @@ * waits at most `timeoutMs` for all of them together. */ +import type { ComposerDraftData } from '@/components/email/email-composer'; import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { OpenDraft } from '@/lib/desktop/window-restore'; export type ComposeFlush = () => Promise; @@ -103,8 +105,33 @@ export async function flushComposers(timeoutMs: number): Promise { return { total: pending.length, settled, failed, timedOut }; } +/** What an open composer holds, for the app lock: where its text is, and the text if it's nowhere else. */ +export interface ComposeSnapshot { + /** In its own window, whose address has to say what comes back after the lock. */ + ownWindow: boolean; + /** The composer's state, when it has anything worth keeping. */ + state(): ComposerDraftData | null; + /** The saved draft it's on. */ + draft(): OpenDraft | null; +} + +const snapshots = new Map(); + +export function registerComposeSnapshot(snapshot: ComposeSnapshot): () => void { + const id = nextId++; + snapshots.set(id, snapshot); + return () => { + snapshots.delete(id); + }; +} + +export function composeSnapshots(): ComposeSnapshot[] { + return [...snapshots.values()]; +} + /** Test helper: forget every registration. */ export function resetComposeFlushRegistry(): void { flushes.clear(); waiters.clear(); + snapshots.clear(); } diff --git a/lib/desktop/app-lock-state.ts b/lib/desktop/app-lock-state.ts new file mode 100644 index 000000000..72432d62b --- /dev/null +++ b/lib/desktop/app-lock-state.ts @@ -0,0 +1,19 @@ +/** + * Whether the app is locked, for everything that would show mail outside the app. + * + * App lock isn't built yet. When it is, its code calls setAppLockCheck once with a + * function that answers from its own state, and calls bridge.os.setLocked on every + * change: main then clears the tray, the jump list and delivered toasts, and strips + * anything that still comes through. Nothing else needs to change. Until then it + * answers false. + */ + +let check: (() => boolean) | null = null; + +export function setAppLockCheck(next: (() => boolean) | null): void { + check = next; +} + +export function isAppLocked(): boolean { + return check?.() === true; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index f9eb6109b..94204e1fa 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -272,6 +272,35 @@ export interface DesktopKeystoreBridge { revokeAccess: () => Promise; } +export type DesktopOsUnlockKind = 'hello' | 'touchid'; + +/** + * App lock in the desktop app. Main holds whether it's locked, and nothing a page says can + * lift that: `prove` needs what only a real unlock gives, Windows Hello and Touch ID run in + * main, and `forget` asks natively while locked. A same-origin plugin frame can reach all + * of this through `parent`, so that has to hold on a call alone. + */ +export interface DesktopAppLockBridge { + status: () => Promise<{ locked: boolean; enrolled: boolean; os: DesktopOsUnlockKind | null; osEnrolled: boolean }>; + /** Locks. Anyone may: it only ever makes things more locked. */ + lock: () => void; + /** Sets up the proof. False when one exists. */ + enroll: (proof: Uint8Array) => Promise; + /** Lifts the lock when the proof is the right one. */ + prove: (proof: Uint8Array) => Promise; + /** The lock goes off for good. */ + disable: (proof: Uint8Array) => Promise; + /** The PIN is lost, or everyone signed out. While locked, main asks the user natively. */ + forget: () => Promise; + /** Asks Windows Hello or Touch ID, then keeps the key in the OS's encrypted storage. */ + osEnroll: (proof: Uint8Array, lockKey: Uint8Array) => Promise; + osRemove: () => Promise; + /** Only while locked. Main asks, and after a yes lifts its lock and hands over the key. */ + osUnlock: () => Promise<{ ok: true; key: Uint8Array } | { ok: false; reason: 'unavailable' | 'denied' | 'failed' | 'not-locked' }>; + /** The screen locked, or "Lock now" in the menu or tray. Returns the unsubscribe. */ + onLockRequest: (listener: () => void) => () => void; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -287,6 +316,7 @@ export interface BulwarkDesktopBridge { auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; keystore?: DesktopKeystoreBridge; + appLock?: DesktopAppLockBridge; } declare global { diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index 59fe37929..3fa2b552f 100644 --- a/lib/offline/__tests__/outbox.test.ts +++ b/lib/offline/__tests__/outbox.test.ts @@ -10,6 +10,7 @@ import { setStoreBackend } from '@/lib/store/client/api'; import { FakeJmapMail } from '@/lib/store/__tests__/helpers/fake-jmap-mail'; import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; import { enforcePolicy, startOfflineMode, wipeEverything } from '../offline-mode'; +import { setLockedHere } from '@/lib/app-lock/state'; import { flushQueue } from '../flush'; import { holdFile, readQueue } from '../queue'; import { forgetLegacyMove } from '../migrate-legacy'; @@ -253,6 +254,45 @@ describe('the outbox', () => { }); }); +describe('the app locking during a flush', () => { + it('stops before the next message, and the rest goes after the unlock', async () => { + const sent: unknown[] = []; + const client = fakeClient({ + sendEmail: vi.fn(async (...args: unknown[]) => { + sent.push(args); + // The timer fires while the first send is under way. + if (sent.length === 1) setLockedHere(true); + return { scheduled: false, emailId: `sent-${sent.length}`, emailSubmissionId: `sub-${sent.length}` }; + }), + }); + useAuthStore.setState({ client: client as never, activeAccountId: 'acc' }); + writeEnabled(['acc']); + stop = startOfflineMode(); + setOnline(false); + await sendArgs(client, { outbox: true }); + await sendArgs(client, { outbox: true }); + // Only the flush below: nothing starts one of its own when the network comes back. + stop(); + stop = () => {}; + setOnline(true); + expect((await readQueue('acc')).outbox).toHaveLength(2); + (client.sendEmail as ReturnType).mockClear(); + try { + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + } finally { + setLockedHere(false); + } + expect(sent).toHaveLength(1); + const waiting = (await readQueue('acc')).outbox; + expect(waiting).toHaveLength(1); + expect(waiting[0].status).toBe('waiting'); + + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(sent).toHaveLength(2); + expect((await readQueue('acc')).outbox).toHaveLength(0); + }); +}); + describe('a send whose response got lost', () => { async function uncertainSend(client: ReturnType) { useAuthStore.setState({ client: client as never, activeAccountId: 'acc' }); diff --git a/lib/offline/flush.ts b/lib/offline/flush.ts index 812d91ddd..1c4729d67 100644 --- a/lib/offline/flush.ts +++ b/lib/offline/flush.ts @@ -26,6 +26,7 @@ * (a label now required, a send check now required) is held for the user. */ +import { isAppLockedNow } from '@/lib/app-lock/state'; import type { JMAPClient } from '@/lib/jmap/client'; import { isNetworkFailure } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; @@ -363,6 +364,8 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps, continue; } // Another tab took it, or it was deleted meanwhile. + // Locked: the rest goes after the unlock, when the app starts again. + if (isAppLockedNow()) return; if (!(await claimForSending(account, entry.id))) continue; let result: SendEmailResult; try { diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 33b5fff76..883bd4f9e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -22,6 +22,7 @@ * (lost.ts). */ +import { appLockKeyLayers, onStoreKeyMissing } from '@/lib/app-lock/controller'; import { IS_LITE } from '@/lib/lite'; import { desktopJmapBase } from '@/lib/desktop/jmap-route'; import { devicesForAccount } from '@/lib/device-policy'; @@ -173,6 +174,8 @@ function noteStoreUnavailable(): void { } const storeHooks: StorePageHooks = { + keyLayers: appLockKeyLayers, + onKeyLocked: onStoreKeyMissing, inputs(account) { const client = onlineClientFor(account); return { diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts index eb3c78134..eab983baf 100644 --- a/lib/popout/channel.ts +++ b/lib/popout/channel.ts @@ -12,6 +12,7 @@ */ import type { ComposerDraftData } from '@/components/email/email-composer'; +import { isAppLockedNow } from '@/lib/app-lock/state'; import type { MessagePriority } from '@/lib/message-priority'; import type { ComposePopoutPayload } from './compose-payload'; import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed'; @@ -154,7 +155,7 @@ export async function requestWindowSession( options: { accountId?: string; rejectedAuthHeader?: string } = {}, timeoutMs: number = SESSION_ANSWER_MS, ): Promise { - if (!canSealSessions()) return null; + if (!canSealSessions() || isAppLockedNow()) return null; const opener = await createSessionOpener(); const rejectedFingerprint = options.rejectedAuthHeader ? await credentialFingerprint(options.rejectedAuthHeader) : undefined; return new Promise((resolve) => { @@ -167,6 +168,11 @@ export async function requestWindowSession( }; const channel = openPopoutChannel((message) => { if (message.type !== 'session-answer' || message.requestId !== requestId) return; + // Locked while the answer was on its way: it is not opened. + if (isAppLockedNow()) { + finish(null); + return; + } void opener.open(message.sealed).then(finish, () => finish(null)); }); if (!channel) { diff --git a/lib/popout/sealed.ts b/lib/popout/sealed.ts index 42fa55e5c..342d01a0a 100644 --- a/lib/popout/sealed.ts +++ b/lib/popout/sealed.ts @@ -46,10 +46,15 @@ async function deriveKey(privateKey: CryptoKey, peer: JsonWebKey): Promise { - const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']); - const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey); +/** + * The asking window's side: a key to send along, and what opens the answer. With + * `stored`, a private key kept elsewhere (app lock's sealed drafts) opens instead. + */ +export async function createSessionOpener(stored?: JsonWebKey): Promise { + const pair = stored + ? { privateKey: await crypto.subtle.importKey('jwk', stored, ECDH, false, ['deriveKey']), publicKey: null } + : await crypto.subtle.generateKey(ECDH, false, ['deriveKey']); + const publicKey = pair.publicKey ? await crypto.subtle.exportKey('jwk', pair.publicKey) : {}; return { publicKey, async open(sealed: SealedAnswer): Promise { diff --git a/lib/settings-search.ts b/lib/settings-search.ts index c3924f6d7..982b15062 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -71,6 +71,8 @@ export const tabSearchEntries: Record = { { label: 'settings.language_region.time_format.label' }, { label: 'settings.language_region.time_zone.label' }, { label: 'settings.language_region.first_day.label' }, + { label: 'appLock.section_title' }, + { label: 'appLock.enable_label' }, { label: 'protocol_handlers.title' }, { label: 'protocol_handlers.mailto_label' }, { label: 'protocol_handlers.webcal_label' }, @@ -373,7 +375,7 @@ export const ARCHIVE_ACTION_SEARCH_PATHS: ReadonlySet = new Set(['settin export const tabKeywords: Record = { account: 'profile email user signin signout reorder rearrange drag dropdown switcher multi-account identities from address signature plus addressing unified mailbox shared group', - general: 'language locale region timezone date time format mailto webcal links default app protocol handler tour onboarding', + general: 'language locale region timezone date time format mailto webcal links default app protocol handler tour onboarding lock pin passkey windows hello touch id', notifications: 'sound alert push badge favicon tab icon unread count background relay device', appearance: 'theme dark light font size accent color animation density custom theme css skin toolbar sidebar account switcher icons rail layout reading pane', accessibility: 'a11y accessible vision blind low vision screen reader contrast motion animation reduce zoom text size font dyslexia spacing keyboard shortcuts focus outline cursor pointer targets colour blind color sound visual alert hearing deaf simple plain language read aloud', diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 531c99f7f..aff8c65e1 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -1,3 +1,4 @@ +import { forgetAppLockAtSignOut } from '@/lib/app-lock/sign-out'; import { fileStorage } from '@/lib/plugin-storage'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; @@ -136,6 +137,8 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { } forgetOfflineShell(); } + // With no store left, the app lock has nothing to guard, and a next person shouldn't meet it. + if (!options.keepLocalStore) forgetAppLockAtSignOut(); // Files and links on their way into a draft or an import. clearHandoffs(); for (const cleanup of signedOutCleanups) { diff --git a/lib/store/client/api.ts b/lib/store/client/api.ts index d339ace69..151818095 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -7,7 +7,7 @@ import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area'; import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; -import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, type WrapLayer } from './keys'; +import { forgetAccountKey, getAccountRecord, knownStoreFiles, listAccountRecords, openAccountKey, type StoreKeyLayerMissingError, type WrapLayer } from './keys'; import { StoreClient } from './store-client'; export interface StoreAccountInputs { @@ -27,6 +27,8 @@ export interface StorePageHooks { keyLayers?(): readonly WrapLayer[]; /** An account this device kept mail for has an empty store now: its key or its file went. */ onStoreLost?(account: string): void; + /** This tab became the owner and has no lock key: the app lock has to ask again. */ + onKeyLocked?(): void; } /** What lib/offline uses. A seam, so its tests run the real engine on node:sqlite. */ @@ -45,6 +47,22 @@ export interface StoreBackend { remove(account: string): Promise; /** Stops this tab's worker at once (a sign-out; the files go separately). */ stop(): void; + /** + * App lock: closes the databases and stops this tab's worker, with every key it had. + * Waits for a clean close at most `closeMs`. Until `unlock`, every call is refused. + */ + lock?(closeMs: number): Promise; + /** This tab has the key again: its worker starts first, and takes the store. */ + unlock?(ownerWaitMs: number): Promise; + ownsStoreHere?(): boolean; +} + +/** The app is locked: the store is closed, and its key isn't in memory anywhere. */ +export class StoreLockedError extends Error { + constructor() { + super('The local store is closed while the app is locked'); + this.name = 'StoreLockedError'; + } } /** How long a call waits for the owner to have opened an account. */ @@ -88,6 +106,8 @@ class PageStore implements StoreBackend { private readonly keyJustMade = new Set(); /** What the owner's engine was last told to keep. */ private readonly sentScope = new Map(); + private locked = false; + private ownerReady: (() => void) | null = null; constructor(private readonly hooks: () => StorePageHooks | null) {} @@ -96,6 +116,7 @@ class PageStore implements StoreBackend { } private started(): StoreClient { + if (this.locked) throw new StoreLockedError(); if (this.client) return this.client; this.broken = false; const client = new StoreClient({ @@ -106,6 +127,7 @@ class PageStore implements StoreBackend { this.hooks()?.onEvent(event); }, onOwner: () => { + this.ownerReady?.(); this.opening.clear(); for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {}); }, @@ -129,13 +151,22 @@ class PageStore implements StoreBackend { this.broken = false; } + private async readKey(account: string): ReturnType { + try { + return await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); + } catch (error) { + if ((error as Error)?.name === 'StoreKeyLayerMissingError' && (error as StoreKeyLayerMissingError).layer === 'applock') this.hooks()?.onKeyLocked?.(); + throw error; + } + } + /** Only the owner may open: the key goes to this tab's own worker, never over the channel. */ private openHere(account: string): Promise { const running = this.opening.get(account); if (running) return running; const run = (async () => { const client = this.started(); - const { keyHex, record, fresh } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); + const { keyHex, record, fresh } = await this.readKey(account); if (fresh) this.keyJustMade.add(account); // The old file, if the key alone went, can never be read again. if (this.keyJustMade.has(account)) await client.pruneFiles(await knownStoreFiles()).catch(() => {}); @@ -161,7 +192,45 @@ class PageStore implements StoreBackend { return run; } + async lock(closeMs: number): Promise { + this.locked = true; + const client = this.client; + this.client = null; + this.opening.clear(); + this.sentScope.clear(); + this.keyJustMade.clear(); + if (!client) return; + let timer: ReturnType | undefined; + await Promise.race([ + client.release().catch(() => {}), + new Promise((resolve) => { timer = setTimeout(resolve, closeMs); }), + ]); + clearTimeout(timer); + // Past the wait a sync may still be running. Stopping the worker ends it, as a crash would. + client.halt(); + } + + async unlock(ownerWaitMs: number): Promise { + this.locked = false; + if (this.client?.ownsStore || !StoreClient.supported()) return; + if ((await listAccountRecords().catch(() => [])).length === 0) return; + await new Promise((resolve) => { + const timer = setTimeout(resolve, ownerWaitMs); + this.ownerReady = () => { + clearTimeout(timer); + this.ownerReady = null; + resolve(); + }; + this.started(); + }); + } + + ownsStoreHere(): boolean { + return this.client?.ownsStore ?? false; + } + async ensureOpen(account: string, session?: StoreSession | null): Promise { + if (this.locked) throw new StoreLockedError(); this.wanted.set(account, session ?? this.wanted.get(account) ?? null); const client = this.started(); if (client.ownsStore) await this.openHere(account); @@ -186,6 +255,7 @@ class PageStore implements StoreBackend { /** A call for an account: the owner may still be opening it, in this tab or another. */ private async whenOpen(account: string, call: (client: StoreClient) => Promise): Promise { + if (this.locked) throw new StoreLockedError(); if (this.broken) throw new Error('The local store could not start'); await this.ensureOpen(account); for (let attempt = 0; ; attempt += 1) { @@ -215,7 +285,7 @@ class PageStore implements StoreBackend { } async setOnline(online: boolean): Promise { - if (!this.client || this.broken) return; + if (this.locked || !this.client || this.broken) return; await this.client.setOnline(online); } diff --git a/locales/ar/common.json b/locales/ar/common.json index b19d3aeeb..6bcc9416a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "تعذّر نسخ الملف." } }, + "appLock": { + "section_title": "قفل التطبيق", + "section_desc": "يطلب رمز PIN بعد فترة دون استخدام، في كل نافذة وعلامة تبويب. أثناء القفل يبقى البريد على هذا الجهاز مشفرًا ولا يملك التطبيق مفتاحه.", + "enable_label": "القفل برمز PIN", + "enable_desc": "متوقف افتراضيًا. يبقى رمز PIN على هذا الجهاز.", + "enable_body": "اختر رمز PIN. ستحتاج إليه كلما قُفل التطبيق، وبعد إعادة التحميل أو بدء التشغيل من جديد.", + "set_pin_title": "اختر رمز PIN", + "change_pin": "تغيير رمز PIN…", + "change_pin_title": "تغيير رمز PIN", + "pin_label": "رمز PIN", + "pin_current": "رمز PIN الحالي", + "pin_new": "رمز PIN الجديد", + "pin_repeat": "أعد إدخال رمز PIN", + "pin_hint": "6 أحرف على الأقل. يمكن استخدام الحروف أيضًا، والرمز الأطول أصعب في التخمين.", + "pin_mismatch": "الرمزان غير متطابقين.", + "pin_recovery": "إذا نسيته، فالحل الوحيد هو إزالة البيانات من هذا الجهاز وتسجيل الدخول من جديد. يبقى بريدك على الخادم. تُفقد الرسائل التي لا تزال تنتظر الإرسال على هذا الجهاز.", + "save": "حفظ", + "cancel": "إلغاء", + "save_failed": "لم ينجح ذلك. حاول مرة أخرى.", + "timeout_label": "القفل بعد", + "timeout_desc": "الوقت دون استخدام في أي نافذة أو علامة تبويب.", + "lock_now": "القفل الآن", + "lock_now_desc": "يقفل كل النوافذ وعلامات التبويب فورًا.", + "disable_title": "إيقاف قفل التطبيق؟", + "disable_body": "أدخل رمز PIN لإيقافه.", + "disable_confirm": "إيقاف", + "passkey_label": "مفتاح المرور", + "passkey_desc": "افتح القفل بمفتاح مرور أو مفتاح أمان يستطيع حماية المفتاح. لا تستطيع كلها ذلك، ويبقى رمز PIN صالحًا.", + "passkey_add": "إضافة مفتاح مرور…", + "passkey_add_title": "إضافة مفتاح مرور", + "passkey_remove": "إزالة", + "passkey_added": "تمت إضافة مفتاح المرور.", + "passkey_no_prf": "لا يستطيع مفتاح المرور هذا حماية المفتاح. يبقى رمز PIN صالحًا.", + "passkey_failed": "لم يعمل مفتاح المرور. أدخل رمز PIN.", + "use_passkey": "استخدام مفتاح مرور", + "use_hello": "استخدام Windows Hello", + "use_touchid": "استخدام Touch ID", + "os_add": "تشغيل…", + "os_add_title": "استخدام {method}", + "os_remove": "إيقاف", + "os_desc": "{method} فحص يجريه التطبيق. بعد الموافقة يقرأ التطبيق المفتاح من التخزين المشفر لهذا الحاسوب. يمكن لبرنامج يعمل تحت حساب المستخدم الخاص بك قراءته هناك دون {method}، لذلك يحمي رمز PIN أكثر. يبقى رمز PIN صالحًا.", + "os_failed": "لم ينجح ذلك. أدخل رمز PIN.", + "lock_title": "{app} مقفل", + "locking": "جارٍ القفل…", + "pin_prompt": "أدخل رمز PIN للمتابعة.", + "unlock": "فتح القفل", + "unlocking": "جارٍ فتح القفل…", + "wrong_pin": "رمز PIN هذا غير صحيح.", + "unlock_failed": "تعذر فتح القفل. حاول مرة أخرى.", + "blocked_later": "محاولات كثيرة جدًا. حاول لاحقًا.", + "forgot": "هل نسيت رمز PIN؟", + "forgot_title": "إزالة البيانات المحلية وتسجيل الخروج؟", + "forgot_body": "لا يمكن استعادة رمز PIN المنسي. يزيل هذا البريد المحفوظ على هذا الجهاز ويسجل خروج كل الحسابات لتتمكن من تسجيل الدخول من جديد. يبقى بريدك على الخادم. تُفقد الرسائل التي لا تزال تنتظر الإرسال على هذا الجهاز.", + "forgot_confirm": "إزالة وتسجيل الخروج", + "unlock_in_main": "افتح القفل في النافذة الرئيسية.", + "open_main": "الانتقال إلى النافذة الرئيسية", + "setup_required_title": "تشترط مؤسستك قفل التطبيق", + "setup_required_body": "اختر رمز PIN. ستحتاج إليه لفتح {app} بعد قفله.", + "draft_saved_notice": "حُفظ ما كنت تكتبه في المسودات قبل القفل.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتان} few {# دقائق} many {# دقيقة} other {# دقيقة}}", + "blocked_seconds": "محاولات كثيرة جدًا. حاول بعد {count, plural, zero {# ثانية} one {ثانية واحدة} two {ثانيتين} few {# ثوانٍ} many {# ثانية} other {# ثانية}}.", + "blocked_minutes": "محاولات كثيرة جدًا. حاول بعد {count, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتين} few {# دقائق} many {# دقيقة} other {# دقيقة}}." + }, "desktop_sign_in": { "browser_button": "سجّل الدخول باستخدام المتصفح", "browser_waiting": "أكمل تسجيل الدخول في المتصفح", diff --git a/locales/ca/common.json b/locales/ca/common.json index b4c960e21..45c349593 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "No s’ha pogut copiar el fitxer." } }, + "appLock": { + "section_title": "Bloqueig de l'app", + "section_desc": "Demana un PIN després d'un temps sense ús, a cada finestra i pestanya. Mentre està bloquejada, el correu d'aquest dispositiu continua xifrat i l'app no en té la clau.", + "enable_label": "Bloqueja amb un PIN", + "enable_desc": "Desactivat per defecte. El PIN es queda en aquest dispositiu.", + "enable_body": "Trieu un PIN. El necessitareu cada vegada que l'app es bloquegi, i després de recarregar o tornar a iniciar.", + "set_pin_title": "Trieu un PIN", + "change_pin": "Canvia el PIN…", + "change_pin_title": "Canvia el PIN", + "pin_label": "PIN", + "pin_current": "PIN actual", + "pin_new": "PIN nou", + "pin_repeat": "Repetiu el PIN", + "pin_hint": "Almenys 6 caràcters. També serveixen lletres, i un PIN més llarg és més difícil d'endevinar.", + "pin_mismatch": "Els dos PIN no coincideixen.", + "pin_recovery": "Si l'oblideu, l'única sortida és eliminar les dades d'aquest dispositiu i tornar a iniciar la sessió. El correu al servidor es manté. Els missatges que encara esperen l'enviament en aquest dispositiu es perden.", + "save": "Desa", + "cancel": "Cancel·la", + "save_failed": "No ha funcionat. Torneu-ho a provar.", + "timeout_label": "Bloqueja després de", + "timeout_desc": "Temps sense ús en qualsevol finestra o pestanya.", + "lock_now": "Bloqueja ara", + "lock_now_desc": "Bloqueja totes les finestres i pestanyes de cop.", + "disable_title": "Voleu desactivar el bloqueig?", + "disable_body": "Escriviu el PIN per desactivar-lo.", + "disable_confirm": "Desactiva", + "passkey_label": "Clau d'accés", + "passkey_desc": "Desbloquegeu amb una clau d'accés o de seguretat que pugui protegir la clau. No totes poden, i el PIN continua funcionant.", + "passkey_add": "Afegeix una clau d'accés…", + "passkey_add_title": "Afegeix una clau d'accés", + "passkey_remove": "Elimina", + "passkey_added": "S'ha afegit la clau d'accés.", + "passkey_no_prf": "Aquesta clau d'accés no pot protegir la clau. El PIN continua funcionant.", + "passkey_failed": "La clau d'accés no ha funcionat. Escriviu el PIN.", + "use_passkey": "Utilitza una clau d'accés", + "use_hello": "Utilitza Windows Hello", + "use_touchid": "Utilitza Touch ID", + "os_add": "Activa…", + "os_add_title": "Utilitza {method}", + "os_remove": "Desactiva", + "os_desc": "{method} és una comprovació de l'app. Després d'un sí, l'app llegeix la clau de l'emmagatzematge xifrat d'aquest ordinador. Un programa que s'executi amb el vostre compte d'usuari la podria llegir allà sense {method}, així que el PIN protegeix més. El PIN continua funcionant.", + "os_failed": "No ha funcionat. Escriviu el PIN.", + "lock_title": "{app} està bloquejada", + "locking": "S'està bloquejant…", + "pin_prompt": "Escriviu el PIN per continuar.", + "unlock": "Desbloqueja", + "unlocking": "S'està desbloquejant…", + "wrong_pin": "Aquest PIN no és correcte.", + "unlock_failed": "No s'ha pogut desbloquejar. Torneu-ho a provar.", + "blocked_later": "Massa intents. Torneu-ho a provar més tard.", + "forgot": "Heu oblidat el PIN?", + "forgot_title": "Voleu eliminar les dades locals i tancar la sessió?", + "forgot_body": "Un PIN oblidat no es pot recuperar. Això elimina el correu desat en aquest dispositiu i tanca la sessió de tots els comptes, perquè pugueu tornar a entrar. El correu al servidor es manté. Els missatges que encara esperen l'enviament en aquest dispositiu es perden.", + "forgot_confirm": "Elimina i tanca la sessió", + "unlock_in_main": "Desbloquegeu a la finestra principal.", + "open_main": "Vés a la finestra principal", + "setup_required_title": "La vostra organització exigeix un bloqueig de l'app", + "setup_required_body": "Trieu un PIN. El necessitareu per obrir {app} quan es bloquegi.", + "draft_saved_notice": "El que escrivíeu s'ha desat a Esborranys abans del bloqueig.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minut} other {# minuts}}", + "blocked_seconds": "Massa intents. Torneu-ho a provar d'aquí a {count, plural, one {# segon} other {# segons}}.", + "blocked_minutes": "Massa intents. Torneu-ho a provar d'aquí a {count, plural, one {# minut} other {# minuts}}." + }, "desktop_sign_in": { "browser_button": "Inicia la sessió amb el navegador", "browser_waiting": "Acaba d'iniciar la sessió al navegador", diff --git a/locales/cs/common.json b/locales/cs/common.json index 28c8b0968..ae73e3d6b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Soubor se nepodařilo zkopírovat." } }, + "appLock": { + "section_title": "Zámek aplikace", + "section_desc": "Po čase bez použití žádá o PIN, v každém okně a na každé kartě. Dokud je zamčeno, zůstává pošta na tomto zařízení zašifrovaná a aplikace k ní nemá klíč.", + "enable_label": "Zamykat kódem PIN", + "enable_desc": "Ve výchozím stavu vypnuto. PIN zůstává na tomto zařízení.", + "enable_body": "Zvolte PIN. Budete ho potřebovat při každém zamčení aplikace a po načtení znovu nebo novém spuštění.", + "set_pin_title": "Zvolte PIN", + "change_pin": "Změnit PIN…", + "change_pin_title": "Změnit PIN", + "pin_label": "PIN", + "pin_current": "Současný PIN", + "pin_new": "Nový PIN", + "pin_repeat": "Zopakujte PIN", + "pin_hint": "Alespoň 6 znaků. Fungují i písmena a delší PIN se hůř uhodne.", + "pin_mismatch": "Oba kódy PIN nejsou stejné.", + "pin_recovery": "Pokud ho zapomenete, jedinou cestou je odebrat data z tohoto zařízení a znovu se přihlásit. Vaše pošta na serveru zůstává. Zprávy čekající na odeslání na tomto zařízení se ztratí.", + "save": "Uložit", + "cancel": "Zrušit", + "save_failed": "Nepodařilo se to. Zkuste to znovu.", + "timeout_label": "Zamknout po", + "timeout_desc": "Čas bez použití v kterémkoli okně nebo kartě.", + "lock_now": "Zamknout teď", + "lock_now_desc": "Zamkne hned všechna okna a karty.", + "disable_title": "Vypnout zámek aplikace?", + "disable_body": "Pro vypnutí zadejte PIN.", + "disable_confirm": "Vypnout", + "passkey_label": "Přístupový klíč", + "passkey_desc": "Odemkněte přístupovým nebo bezpečnostním klíčem, který umí chránit klíč. Ne všechny to umí a PIN dál funguje.", + "passkey_add": "Přidat přístupový klíč…", + "passkey_add_title": "Přidat přístupový klíč", + "passkey_remove": "Odebrat", + "passkey_added": "Přístupový klíč byl přidán.", + "passkey_no_prf": "Tento přístupový klíč nemůže chránit klíč. PIN dál funguje.", + "passkey_failed": "Přístupový klíč nefungoval. Zadejte PIN.", + "use_passkey": "Použít přístupový klíč", + "use_hello": "Použít Windows Hello", + "use_touchid": "Použít Touch ID", + "os_add": "Zapnout…", + "os_add_title": "Použít {method}", + "os_remove": "Vypnout", + "os_desc": "{method} je kontrola, kterou provádí aplikace. Po potvrzení aplikace přečte klíč ze šifrovaného úložiště tohoto počítače. Program běžící pod vaším uživatelským účtem by ho tam mohl přečíst i bez toho, takže PIN chrání víc. PIN dál funguje.", + "os_failed": "Nepodařilo se to. Zadejte PIN.", + "lock_title": "{app} je zamčená", + "locking": "Zamykání…", + "pin_prompt": "Pokračujte zadáním kódu PIN.", + "unlock": "Odemknout", + "unlocking": "Odemykání…", + "wrong_pin": "Tento PIN není správný.", + "unlock_failed": "Odemknutí se nepodařilo. Zkuste to znovu.", + "blocked_later": "Příliš mnoho pokusů. Zkuste to později.", + "forgot": "Zapomněli jste PIN?", + "forgot_title": "Odebrat místní data a odhlásit se?", + "forgot_body": "Zapomenutý PIN nelze obnovit. Tím se odebere pošta uložená na tomto zařízení a odhlásí se všechny účty, abyste se mohli znovu přihlásit. Vaše pošta na serveru zůstává. Zprávy čekající na odeslání na tomto zařízení se ztratí.", + "forgot_confirm": "Odebrat a odhlásit", + "unlock_in_main": "Odemkněte v hlavním okně.", + "open_main": "Přejít do hlavního okna", + "setup_required_title": "Vaše organizace vyžaduje zámek aplikace", + "setup_required_body": "Zvolte PIN. Budete ho potřebovat k otevření {app} po zamčení.", + "draft_saved_notice": "To, co jste psali, se před zamčením uložilo do konceptů.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuta} few {# minuty} many {# minuty} other {# minut}}", + "blocked_seconds": "Příliš mnoho pokusů. Zkuste to za {count, plural, one {# sekundu} few {# sekundy} many {# sekundy} other {# sekund}}.", + "blocked_minutes": "Příliš mnoho pokusů. Zkuste to za {count, plural, one {# minutu} few {# minuty} many {# minuty} other {# minut}}." + }, "desktop_sign_in": { "browser_button": "Přihlásit se v prohlížeči", "browser_waiting": "Dokončete přihlášení v prohlížeči", diff --git a/locales/da/common.json b/locales/da/common.json index e47034d0e..0eeda37fb 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Filen kunne ikke kopieres." } }, + "appLock": { + "section_title": "Applås", + "section_desc": "Beder om en pinkode efter en tid uden brug, i alle vinduer og faner. Mens den er låst, forbliver mail på denne enhed krypteret, og appen har ingen nøgle til den.", + "enable_label": "Lås med en pinkode", + "enable_desc": "Slået fra som standard. Pinkoden bliver på denne enhed.", + "enable_body": "Vælg en pinkode. Du skal bruge den, hver gang appen låser, og efter en genindlæsning eller ny start.", + "set_pin_title": "Vælg en pinkode", + "change_pin": "Skift pinkode…", + "change_pin_title": "Skift pinkoden", + "pin_label": "Pinkode", + "pin_current": "Nuværende pinkode", + "pin_new": "Ny pinkode", + "pin_repeat": "Gentag pinkoden", + "pin_hint": "Mindst 6 tegn. Bogstaver virker også, og en længere pinkode er sværere at gætte.", + "pin_mismatch": "De to pinkoder er ikke ens.", + "pin_recovery": "Hvis du glemmer den, er eneste udvej at fjerne dataene på denne enhed og logge ind igen. Din mail på serveren bliver. Beskeder, der stadig venter på at blive sendt fra denne enhed, går tabt.", + "save": "Gem", + "cancel": "Annuller", + "save_failed": "Det virkede ikke. Prøv igen.", + "timeout_label": "Lås efter", + "timeout_desc": "Tid uden brug i et vindue eller en fane.", + "lock_now": "Lås nu", + "lock_now_desc": "Låser alle vinduer og faner med det samme.", + "disable_title": "Slå applåsen fra?", + "disable_body": "Indtast din pinkode for at slå den fra.", + "disable_confirm": "Slå fra", + "passkey_label": "Adgangsnøgle", + "passkey_desc": "Lås op med en adgangsnøgle eller sikkerhedsnøgle, der kan beskytte nøglen. Det kan ikke alle, og pinkoden virker stadig.", + "passkey_add": "Tilføj en adgangsnøgle…", + "passkey_add_title": "Tilføj en adgangsnøgle", + "passkey_remove": "Fjern", + "passkey_added": "Adgangsnøglen er tilføjet.", + "passkey_no_prf": "Denne adgangsnøgle kan ikke beskytte nøglen. Din pinkode virker stadig.", + "passkey_failed": "Adgangsnøglen virkede ikke. Indtast din pinkode.", + "use_passkey": "Brug en adgangsnøgle", + "use_hello": "Brug Windows Hello", + "use_touchid": "Brug Touch ID", + "os_add": "Slå til…", + "os_add_title": "Brug {method}", + "os_remove": "Slå fra", + "os_desc": "{method} er en kontrol, appen foretager. Efter et ja læser appen nøglen fra computerens krypterede lager. Et program under din brugerkonto kan læse den der uden {method}, så pinkoden beskytter mere. Pinkoden virker stadig.", + "os_failed": "Det virkede ikke. Indtast din pinkode.", + "lock_title": "{app} er låst", + "locking": "Låser…", + "pin_prompt": "Indtast din pinkode for at fortsætte.", + "unlock": "Lås op", + "unlocking": "Låser op…", + "wrong_pin": "Den pinkode er ikke rigtig.", + "unlock_failed": "Oplåsning virkede ikke. Prøv igen.", + "blocked_later": "For mange forsøg. Prøv igen senere.", + "forgot": "Glemt din pinkode?", + "forgot_title": "Fjern lokale data og log ud?", + "forgot_body": "En glemt pinkode kan ikke gendannes. Dette fjerner mail gemt på denne enhed og logger alle konti ud, så du kan logge ind igen. Din mail på serveren bliver. Beskeder, der stadig venter på at blive sendt fra denne enhed, går tabt.", + "forgot_confirm": "Fjern og log ud", + "unlock_in_main": "Lås op i hovedvinduet.", + "open_main": "Gå til hovedvinduet", + "setup_required_title": "Din organisation kræver en applås", + "setup_required_body": "Vælg en pinkode. Du skal bruge den for at åbne {app}, når den er låst.", + "draft_saved_notice": "Det, du skrev, blev gemt i Kladder, før appen låste.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minut} other {# minutter}}", + "blocked_seconds": "For mange forsøg. Prøv igen om {count, plural, one {# sekund} other {# sekunder}}.", + "blocked_minutes": "For mange forsøg. Prøv igen om {count, plural, one {# minut} other {# minutter}}." + }, "desktop_sign_in": { "browser_button": "Log ind med din browser", "browser_waiting": "Gør login færdigt i din browser", diff --git a/locales/de/common.json b/locales/de/common.json index ca3134e5d..558ac8c90 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Die Datei konnte nicht kopiert werden." } }, + "appLock": { + "section_title": "App-Sperre", + "section_desc": "Fragt nach einer Zeit ohne Nutzung in jedem Fenster und Tab nach einer PIN. Solange gesperrt ist, bleiben E-Mails auf diesem Gerät verschlüsselt und die App hat keinen Schlüssel dafür.", + "enable_label": "Mit PIN sperren", + "enable_desc": "Standardmäßig aus. Die PIN bleibt auf diesem Gerät.", + "enable_body": "Wählen Sie eine PIN. Sie brauchen sie jedes Mal, wenn die App sperrt, und nach einem Neuladen oder Neustart.", + "set_pin_title": "PIN wählen", + "change_pin": "PIN ändern…", + "change_pin_title": "PIN ändern", + "pin_label": "PIN", + "pin_current": "Aktuelle PIN", + "pin_new": "Neue PIN", + "pin_repeat": "PIN wiederholen", + "pin_hint": "Mindestens 6 Zeichen. Buchstaben gehen auch, und eine längere PIN ist schwerer zu erraten.", + "pin_mismatch": "Die beiden PINs sind nicht gleich.", + "pin_recovery": "Wenn Sie sie vergessen, müssen Sie die Daten auf diesem Gerät entfernen und sich neu anmelden. Ihre E-Mails auf dem Server bleiben. Nachrichten, die auf diesem Gerät noch auf den Versand warten, gehen verloren.", + "save": "Speichern", + "cancel": "Abbrechen", + "save_failed": "Das hat nicht geklappt. Versuchen Sie es noch einmal.", + "timeout_label": "Sperren nach", + "timeout_desc": "Zeit ohne Nutzung in irgendeinem Fenster oder Tab.", + "lock_now": "Jetzt sperren", + "lock_now_desc": "Sperrt alle Fenster und Tabs sofort.", + "disable_title": "App-Sperre ausschalten?", + "disable_body": "Geben Sie Ihre PIN ein, um sie auszuschalten.", + "disable_confirm": "Ausschalten", + "passkey_label": "Passkey", + "passkey_desc": "Entsperren mit einem Passkey oder Sicherheitsschlüssel, der den Schlüssel schützen kann. Das können nicht alle, und die PIN funktioniert weiter.", + "passkey_add": "Passkey hinzufügen…", + "passkey_add_title": "Passkey hinzufügen", + "passkey_remove": "Entfernen", + "passkey_added": "Passkey hinzugefügt.", + "passkey_no_prf": "Dieser Passkey kann den Schlüssel nicht schützen. Ihre PIN funktioniert weiter.", + "passkey_failed": "Der Passkey hat nicht funktioniert. Geben Sie stattdessen Ihre PIN ein.", + "use_passkey": "Passkey verwenden", + "use_hello": "Windows Hello verwenden", + "use_touchid": "Touch ID verwenden", + "os_add": "Einschalten…", + "os_add_title": "{method} verwenden", + "os_remove": "Ausschalten", + "os_desc": "{method} ist eine Prüfung durch die App. Nach einem Ja liest die App den Schlüssel aus dem verschlüsselten Speicher dieses Computers. Ein Programm unter Ihrem Benutzerkonto könnte ihn dort auch ohne {method} lesen, die PIN schützt also mehr. Die PIN funktioniert weiter.", + "os_failed": "Das hat nicht geklappt. Geben Sie stattdessen Ihre PIN ein.", + "lock_title": "{app} ist gesperrt", + "locking": "Wird gesperrt…", + "pin_prompt": "Geben Sie Ihre PIN ein, um fortzufahren.", + "unlock": "Entsperren", + "unlocking": "Wird entsperrt…", + "wrong_pin": "Diese PIN stimmt nicht.", + "unlock_failed": "Das Entsperren hat nicht geklappt. Versuchen Sie es noch einmal.", + "blocked_later": "Zu viele Versuche. Versuchen Sie es später noch einmal.", + "forgot": "PIN vergessen?", + "forgot_title": "Lokale Daten entfernen und abmelden?", + "forgot_body": "Eine vergessene PIN lässt sich nicht wiederherstellen. Dies entfernt die E-Mails auf diesem Gerät und meldet alle Konten ab, damit Sie sich neu anmelden können. Ihre E-Mails auf dem Server bleiben. Nachrichten, die auf diesem Gerät noch auf den Versand warten, gehen verloren.", + "forgot_confirm": "Entfernen und abmelden", + "unlock_in_main": "Entsperren Sie im Hauptfenster.", + "open_main": "Zum Hauptfenster", + "setup_required_title": "Ihre Organisation verlangt eine App-Sperre", + "setup_required_body": "Wählen Sie eine PIN. Sie brauchen sie, um {app} nach dem Sperren zu öffnen.", + "draft_saved_notice": "Was Sie geschrieben haben, wurde vor dem Sperren in den Entwürfen gespeichert.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# Minute} other {# Minuten}}", + "blocked_seconds": "Zu viele Versuche. Versuchen Sie es in {count, plural, one {# Sekunde} other {# Sekunden}} noch einmal.", + "blocked_minutes": "Zu viele Versuche. Versuchen Sie es in {count, plural, one {# Minute} other {# Minuten}} noch einmal." + }, "desktop_sign_in": { "browser_button": "Mit dem Browser anmelden", "browser_waiting": "Schließen Sie die Anmeldung im Browser ab", diff --git a/locales/en/common.json b/locales/en/common.json index e178107a0..b45b905f7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "The file could not be copied." } }, + "appLock": { + "section_title": "App lock", + "section_desc": "Asks for a PIN after a time without use, in every window and tab. While it's locked, mail on this device stays encrypted and the app has no key for it.", + "enable_label": "Lock with a PIN", + "enable_desc": "Off by default. The PIN stays on this device.", + "enable_body": "Choose a PIN. You need it each time the app locks, and after a reload or a new start.", + "set_pin_title": "Choose a PIN", + "change_pin": "Change PIN…", + "change_pin_title": "Change the PIN", + "pin_label": "PIN", + "pin_current": "Current PIN", + "pin_new": "New PIN", + "pin_repeat": "Repeat the PIN", + "pin_hint": "At least 6 characters. Letters work too, and a longer PIN is harder to guess.", + "pin_mismatch": "The two PINs are not the same.", + "pin_recovery": "If you forget it, the only way back is to remove the data on this device and sign in again. Your mail on the server stays. Messages still waiting to be sent on this device are lost.", + "save": "Save", + "cancel": "Cancel", + "save_failed": "That did not work. Try again.", + "timeout_label": "Lock after", + "timeout_desc": "Time without use in any window or tab.", + "lock_now": "Lock now", + "lock_now_desc": "Locks all windows and tabs at once.", + "disable_title": "Turn off the app lock?", + "disable_body": "Enter your PIN to turn it off.", + "disable_confirm": "Turn off", + "passkey_label": "Passkey", + "passkey_desc": "Unlock with a passkey or security key that can protect the key. Not all can, and the PIN keeps working.", + "passkey_add": "Add a passkey…", + "passkey_add_title": "Add a passkey", + "passkey_remove": "Remove", + "passkey_added": "Passkey added.", + "passkey_no_prf": "This passkey can't protect the key. Your PIN keeps working.", + "passkey_failed": "The passkey did not work. Enter your PIN instead.", + "use_passkey": "Use a passkey", + "use_hello": "Use Windows Hello", + "use_touchid": "Use Touch ID", + "os_add": "Turn on…", + "os_add_title": "Use {method}", + "os_remove": "Turn off", + "os_desc": "{method} is a check by the app. After a yes, the app reads the key from this computer's encrypted storage. A program running under your user account could read it there without {method}, so the PIN protects more. The PIN keeps working.", + "os_failed": "That did not work. Enter your PIN instead.", + "lock_title": "{app} is locked", + "locking": "Locking…", + "pin_prompt": "Enter your PIN to continue.", + "unlock": "Unlock", + "unlocking": "Unlocking…", + "wrong_pin": "That PIN is not right.", + "unlock_failed": "Unlocking did not work. Try again.", + "blocked_later": "Too many tries. Try again later.", + "forgot": "Forgot your PIN?", + "forgot_title": "Remove local data and sign out?", + "forgot_body": "A forgotten PIN can't be recovered. This removes the mail kept on this device and signs out all accounts, so you can sign in again. Your mail on the server stays. Messages still waiting to be sent on this device are lost.", + "forgot_confirm": "Remove and sign out", + "unlock_in_main": "Unlock in the main window.", + "open_main": "Go to the main window", + "setup_required_title": "Your organisation requires an app lock", + "setup_required_body": "Choose a PIN. You need it to open {app} after it locks.", + "draft_saved_notice": "What you were writing was saved to Drafts before the app locked.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minute} other {# minutes}}", + "blocked_seconds": "Too many tries. Try again in {count, plural, one {# second} other {# seconds}}.", + "blocked_minutes": "Too many tries. Try again in {count, plural, one {# minute} other {# minutes}}." + }, "desktop_sign_in": { "browser_button": "Sign in with your browser", "browser_waiting": "Finish signing in in your browser", diff --git a/locales/es/common.json b/locales/es/common.json index 92ae5cfc1..9169ff9e2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "No se pudo copiar el archivo." } }, + "appLock": { + "section_title": "Bloqueo de la app", + "section_desc": "Pide un PIN tras un tiempo sin uso, en cada ventana y pestaña. Mientras está bloqueada, el correo de este dispositivo sigue cifrado y la app no tiene la clave.", + "enable_label": "Bloquear con un PIN", + "enable_desc": "Desactivado de forma predeterminada. El PIN se queda en este dispositivo.", + "enable_body": "Elija un PIN. Lo necesitará cada vez que la app se bloquee, y tras recargar o volver a iniciar.", + "set_pin_title": "Elegir un PIN", + "change_pin": "Cambiar PIN…", + "change_pin_title": "Cambiar el PIN", + "pin_label": "PIN", + "pin_current": "PIN actual", + "pin_new": "PIN nuevo", + "pin_repeat": "Repetir el PIN", + "pin_hint": "Al menos 6 caracteres. También valen letras, y un PIN más largo es más difícil de adivinar.", + "pin_mismatch": "Los dos PIN no coinciden.", + "pin_recovery": "Si lo olvida, la única salida es eliminar los datos de este dispositivo y volver a iniciar sesión. Su correo en el servidor se mantiene. Los mensajes que aún esperan envío en este dispositivo se pierden.", + "save": "Guardar", + "cancel": "Cancelar", + "save_failed": "No ha funcionado. Inténtelo de nuevo.", + "timeout_label": "Bloquear tras", + "timeout_desc": "Tiempo sin uso en cualquier ventana o pestaña.", + "lock_now": "Bloquear ahora", + "lock_now_desc": "Bloquea todas las ventanas y pestañas a la vez.", + "disable_title": "¿Desactivar el bloqueo?", + "disable_body": "Escriba su PIN para desactivarlo.", + "disable_confirm": "Desactivar", + "passkey_label": "Llave de acceso", + "passkey_desc": "Desbloquee con una llave de acceso o de seguridad que pueda proteger la clave. No todas pueden, y el PIN sigue funcionando.", + "passkey_add": "Añadir una llave de acceso…", + "passkey_add_title": "Añadir una llave de acceso", + "passkey_remove": "Quitar", + "passkey_added": "Llave de acceso añadida.", + "passkey_no_prf": "Esta llave de acceso no puede proteger la clave. Su PIN sigue funcionando.", + "passkey_failed": "La llave de acceso no ha funcionado. Escriba su PIN.", + "use_passkey": "Usar una llave de acceso", + "use_hello": "Usar Windows Hello", + "use_touchid": "Usar Touch ID", + "os_add": "Activar…", + "os_add_title": "Usar {method}", + "os_remove": "Desactivar", + "os_desc": "{method} es una comprobación de la app. Tras un sí, la app lee la clave del almacenamiento cifrado de este equipo. Un programa que se ejecute con su cuenta de usuario podría leerla allí sin {method}, así que el PIN protege más. El PIN sigue funcionando.", + "os_failed": "No ha funcionado. Escriba su PIN.", + "lock_title": "{app} está bloqueada", + "locking": "Bloqueando…", + "pin_prompt": "Escriba su PIN para continuar.", + "unlock": "Desbloquear", + "unlocking": "Desbloqueando…", + "wrong_pin": "Ese PIN no es correcto.", + "unlock_failed": "No se ha podido desbloquear. Inténtelo de nuevo.", + "blocked_later": "Demasiados intentos. Inténtelo más tarde.", + "forgot": "¿Ha olvidado su PIN?", + "forgot_title": "¿Eliminar los datos locales y cerrar sesión?", + "forgot_body": "Un PIN olvidado no se puede recuperar. Esto elimina el correo guardado en este dispositivo y cierra la sesión de todas las cuentas, para que pueda volver a entrar. Su correo en el servidor se mantiene. Los mensajes que aún esperan envío en este dispositivo se pierden.", + "forgot_confirm": "Eliminar y cerrar sesión", + "unlock_in_main": "Desbloquee en la ventana principal.", + "open_main": "Ir a la ventana principal", + "setup_required_title": "Su organización exige un bloqueo de la app", + "setup_required_body": "Elija un PIN. Lo necesitará para abrir {app} cuando se bloquee.", + "draft_saved_notice": "Lo que estaba escribiendo se guardó en Borradores antes del bloqueo.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuto} other {# minutos}}", + "blocked_seconds": "Demasiados intentos. Inténtelo de nuevo en {count, plural, one {# segundo} other {# segundos}}.", + "blocked_minutes": "Demasiados intentos. Inténtelo de nuevo en {count, plural, one {# minuto} other {# minutos}}." + }, "desktop_sign_in": { "browser_button": "Iniciar sesión con el navegador", "browser_waiting": "Termina de iniciar sesión en el navegador", diff --git a/locales/et/common.json b/locales/et/common.json index af5abde97..b6d233a3e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Faili ei saanud kopeerida." } }, + "appLock": { + "section_title": "Rakenduse lukk", + "section_desc": "Küsib pärast kasutamata aega PIN-koodi, igas aknas ja vahekaardil. Lukus olles jääb selle seadme e-post krüpteerituks ja rakendusel pole selle võtit.", + "enable_label": "Lukusta PIN-koodiga", + "enable_desc": "Vaikimisi väljas. PIN-kood jääb sellesse seadmesse.", + "enable_body": "Valige PIN-kood. Seda on vaja iga kord, kui rakendus lukustub, ja pärast uuesti laadimist või käivitamist.", + "set_pin_title": "Valige PIN-kood", + "change_pin": "Muuda PIN-koodi…", + "change_pin_title": "Muuda PIN-koodi", + "pin_label": "PIN-kood", + "pin_current": "Praegune PIN-kood", + "pin_new": "Uus PIN-kood", + "pin_repeat": "Korrake PIN-koodi", + "pin_hint": "Vähemalt 6 märki. Sobivad ka tähed ja pikemat PIN-koodi on raskem ära arvata.", + "pin_mismatch": "Need kaks PIN-koodi ei ole samad.", + "pin_recovery": "Kui selle unustate, on ainus võimalus kustutada selle seadme andmed ja uuesti sisse logida. Teie e-post serveris jääb alles. Selles seadmes saatmist ootavad kirjad lähevad kaotsi.", + "save": "Salvesta", + "cancel": "Tühista", + "save_failed": "See ei õnnestunud. Proovige uuesti.", + "timeout_label": "Lukusta pärast", + "timeout_desc": "Kasutamata aeg mis tahes aknas või vahekaardil.", + "lock_now": "Lukusta kohe", + "lock_now_desc": "Lukustab kõik aknad ja vahekaardid korraga.", + "disable_title": "Kas lülitada rakenduse lukk välja?", + "disable_body": "Väljalülitamiseks sisestage PIN-kood.", + "disable_confirm": "Lülita välja", + "passkey_label": "Pääsukood", + "passkey_desc": "Avage pääsukoodi või turvavõtmega, mis suudab võtit kaitsta. Kõik ei suuda ja PIN-kood töötab edasi.", + "passkey_add": "Lisa pääsukood…", + "passkey_add_title": "Lisa pääsukood", + "passkey_remove": "Eemalda", + "passkey_added": "Pääsukood on lisatud.", + "passkey_no_prf": "See pääsukood ei suuda võtit kaitsta. PIN-kood töötab edasi.", + "passkey_failed": "Pääsukood ei töötanud. Sisestage PIN-kood.", + "use_passkey": "Kasuta pääsukoodi", + "use_hello": "Kasuta Windows Hellot", + "use_touchid": "Kasuta Touch ID-d", + "os_add": "Lülita sisse…", + "os_add_title": "Kasuta: {method}", + "os_remove": "Lülita välja", + "os_desc": "{method} on rakenduse tehtav kontroll. Pärast jah-vastust loeb rakendus võtme selle arvuti krüpteeritud hoidlast. Teie kasutajakonto all töötav programm võiks selle sealt lugeda ka ilma, seega kaitseb PIN-kood rohkem. PIN-kood töötab edasi.", + "os_failed": "See ei õnnestunud. Sisestage PIN-kood.", + "lock_title": "{app} on lukus", + "locking": "Lukustamine…", + "pin_prompt": "Jätkamiseks sisestage PIN-kood.", + "unlock": "Ava", + "unlocking": "Avamine…", + "wrong_pin": "See PIN-kood ei ole õige.", + "unlock_failed": "Avamine ei õnnestunud. Proovige uuesti.", + "blocked_later": "Liiga palju katseid. Proovige hiljem uuesti.", + "forgot": "Unustasite PIN-koodi?", + "forgot_title": "Kas eemaldada kohalikud andmed ja logida välja?", + "forgot_body": "Unustatud PIN-koodi ei saa taastada. See eemaldab selles seadmes hoitud e-posti ja logib kõik kontod välja, et saaksite uuesti sisse logida. Teie e-post serveris jääb alles. Selles seadmes saatmist ootavad kirjad lähevad kaotsi.", + "forgot_confirm": "Eemalda ja logi välja", + "unlock_in_main": "Avage põhiaknas.", + "open_main": "Mine põhiaknasse", + "setup_required_title": "Teie organisatsioon nõuab rakenduse lukku", + "setup_required_body": "Valige PIN-kood. Seda on vaja {app} avamiseks pärast lukustumist.", + "draft_saved_notice": "Kirjutatu salvestati enne lukustumist mustanditesse.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuti} other {# minuti}}", + "blocked_seconds": "Liiga palju katseid. Proovige uuesti {count, plural, one {# sekundi} other {# sekundi}} pärast.", + "blocked_minutes": "Liiga palju katseid. Proovige uuesti {count, plural, one {# minuti} other {# minuti}} pärast." + }, "desktop_sign_in": { "browser_button": "Logi sisse brauseriga", "browser_waiting": "Lõpeta sisselogimine brauseris", diff --git a/locales/fa/common.json b/locales/fa/common.json index b25da5a9b..070b1b485 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "فایل کپی نشد." } }, + "appLock": { + "section_title": "قفل برنامه", + "section_desc": "پس از مدتی بی‌استفاده، در هر پنجره و زبانه پین می‌خواهد. تا وقتی قفل است، ایمیل‌های این دستگاه رمزگذاری‌شده می‌مانند و برنامه کلید آن‌ها را ندارد.", + "enable_label": "قفل با پین", + "enable_desc": "به‌طور پیش‌فرض خاموش است. پین روی همین دستگاه می‌ماند.", + "enable_body": "یک پین انتخاب کنید. هر بار که برنامه قفل شود و پس از بارگذاری دوباره یا شروع تازه به آن نیاز دارید.", + "set_pin_title": "انتخاب پین", + "change_pin": "تغییر پین…", + "change_pin_title": "تغییر پین", + "pin_label": "پین", + "pin_current": "پین فعلی", + "pin_new": "پین جدید", + "pin_repeat": "تکرار پین", + "pin_hint": "دست‌کم ۶ نویسه. حروف هم پذیرفته می‌شوند و حدس زدن پین بلندتر سخت‌تر است.", + "pin_mismatch": "این دو پین یکسان نیستند.", + "pin_recovery": "اگر آن را فراموش کنید، تنها راه حذف داده‌های این دستگاه و ورود دوباره است. ایمیل‌های شما روی سرور می‌مانند. پیام‌هایی که هنوز در این دستگاه منتظر ارسال‌اند از بین می‌روند.", + "save": "ذخیره", + "cancel": "لغو", + "save_failed": "انجام نشد. دوباره تلاش کنید.", + "timeout_label": "قفل پس از", + "timeout_desc": "زمان بی‌استفاده در هر پنجره یا زبانه.", + "lock_now": "اکنون قفل کن", + "lock_now_desc": "همه پنجره‌ها و زبانه‌ها را فوراً قفل می‌کند.", + "disable_title": "قفل برنامه خاموش شود؟", + "disable_body": "برای خاموش کردن، پین خود را وارد کنید.", + "disable_confirm": "خاموش کردن", + "passkey_label": "کلید عبور", + "passkey_desc": "با کلید عبور یا کلید امنیتی که بتواند از کلید محافظت کند باز کنید. همه نمی‌توانند و پین همچنان کار می‌کند.", + "passkey_add": "افزودن کلید عبور…", + "passkey_add_title": "افزودن کلید عبور", + "passkey_remove": "حذف", + "passkey_added": "کلید عبور افزوده شد.", + "passkey_no_prf": "این کلید عبور نمی‌تواند از کلید محافظت کند. پین شما همچنان کار می‌کند.", + "passkey_failed": "کلید عبور کار نکرد. پین خود را وارد کنید.", + "use_passkey": "استفاده از کلید عبور", + "use_hello": "استفاده از Windows Hello", + "use_touchid": "استفاده از Touch ID", + "os_add": "روشن کردن…", + "os_add_title": "استفاده از {method}", + "os_remove": "خاموش کردن", + "os_desc": "{method} بررسی‌ای است که برنامه انجام می‌دهد. پس از تأیید، برنامه کلید را از فضای رمزگذاری‌شده این رایانه می‌خواند. برنامه‌ای که زیر حساب کاربری شما اجرا شود می‌تواند آن را بدون {method} هم بخواند، پس پین محافظت بیشتری دارد. پین همچنان کار می‌کند.", + "os_failed": "انجام نشد. پین خود را وارد کنید.", + "lock_title": "{app} قفل است", + "locking": "در حال قفل کردن…", + "pin_prompt": "برای ادامه، پین خود را وارد کنید.", + "unlock": "باز کردن قفل", + "unlocking": "در حال باز کردن قفل…", + "wrong_pin": "این پین درست نیست.", + "unlock_failed": "باز کردن قفل انجام نشد. دوباره تلاش کنید.", + "blocked_later": "تلاش‌های زیاد. بعداً دوباره تلاش کنید.", + "forgot": "پین خود را فراموش کرده‌اید؟", + "forgot_title": "داده‌های محلی حذف و از حساب خارج شوید؟", + "forgot_body": "پین فراموش‌شده بازیابی نمی‌شود. این کار ایمیل‌های نگه‌داشته‌شده در این دستگاه را حذف می‌کند و از همه حساب‌ها خارج می‌شود تا بتوانید دوباره وارد شوید. ایمیل‌های شما روی سرور می‌مانند. پیام‌هایی که هنوز در این دستگاه منتظر ارسال‌اند از بین می‌روند.", + "forgot_confirm": "حذف و خروج", + "unlock_in_main": "در پنجره اصلی قفل را باز کنید.", + "open_main": "رفتن به پنجره اصلی", + "setup_required_title": "سازمان شما قفل برنامه را الزامی کرده است", + "setup_required_body": "یک پین انتخاب کنید. برای باز کردن {app} پس از قفل شدن به آن نیاز دارید.", + "draft_saved_notice": "آنچه می‌نوشتید پیش از قفل شدن در پیش‌نویس‌ها ذخیره شد.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# دقیقه} other {# دقیقه}}", + "blocked_seconds": "تلاش‌های زیاد. {count, plural, one {# ثانیه} other {# ثانیه}} دیگر دوباره تلاش کنید.", + "blocked_minutes": "تلاش‌های زیاد. {count, plural, one {# دقیقه} other {# دقیقه}} دیگر دوباره تلاش کنید." + }, "desktop_sign_in": { "browser_button": "ورود با مرورگر", "browser_waiting": "ورود را در مرورگر کامل کنید", diff --git a/locales/fr/common.json b/locales/fr/common.json index a283bf2a2..859ff160f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Impossible de copier le fichier." } }, + "appLock": { + "section_title": "Verrouillage de l'app", + "section_desc": "Demande un code PIN après un temps sans utilisation, dans chaque fenêtre et onglet. Tant que l'app est verrouillée, les e-mails sur cet appareil restent chiffrés et l'app n'a pas de clé pour les lire.", + "enable_label": "Verrouiller avec un code PIN", + "enable_desc": "Désactivé par défaut. Le code PIN reste sur cet appareil.", + "enable_body": "Choisissez un code PIN. Il vous sera demandé à chaque verrouillage, et après un rechargement ou un nouveau démarrage.", + "set_pin_title": "Choisir un code PIN", + "change_pin": "Changer le code PIN…", + "change_pin_title": "Changer le code PIN", + "pin_label": "Code PIN", + "pin_current": "Code PIN actuel", + "pin_new": "Nouveau code PIN", + "pin_repeat": "Répéter le code PIN", + "pin_hint": "Au moins 6 caractères. Les lettres sont acceptées, et un code plus long est plus difficile à deviner.", + "pin_mismatch": "Les deux codes PIN ne sont pas identiques.", + "pin_recovery": "Si vous l'oubliez, la seule solution est de supprimer les données de cet appareil et de vous reconnecter. Vos e-mails sur le serveur restent. Les messages encore en attente d'envoi sur cet appareil sont perdus.", + "save": "Enregistrer", + "cancel": "Annuler", + "save_failed": "Cela n'a pas fonctionné. Réessayez.", + "timeout_label": "Verrouiller après", + "timeout_desc": "Temps sans utilisation dans une fenêtre ou un onglet.", + "lock_now": "Verrouiller maintenant", + "lock_now_desc": "Verrouille toutes les fenêtres et tous les onglets immédiatement.", + "disable_title": "Désactiver le verrouillage ?", + "disable_body": "Saisissez votre code PIN pour le désactiver.", + "disable_confirm": "Désactiver", + "passkey_label": "Clé d'accès", + "passkey_desc": "Déverrouillez avec une clé d'accès ou une clé de sécurité capable de protéger la clé. Toutes n'en sont pas capables, et le code PIN continue de fonctionner.", + "passkey_add": "Ajouter une clé d'accès…", + "passkey_add_title": "Ajouter une clé d'accès", + "passkey_remove": "Supprimer", + "passkey_added": "Clé d'accès ajoutée.", + "passkey_no_prf": "Cette clé d'accès ne peut pas protéger la clé. Votre code PIN continue de fonctionner.", + "passkey_failed": "La clé d'accès n'a pas fonctionné. Saisissez plutôt votre code PIN.", + "use_passkey": "Utiliser une clé d'accès", + "use_hello": "Utiliser Windows Hello", + "use_touchid": "Utiliser Touch ID", + "os_add": "Activer…", + "os_add_title": "Utiliser {method}", + "os_remove": "Désactiver", + "os_desc": "{method} est une vérification faite par l'app. Après un oui, l'app lit la clé dans le stockage chiffré de cet ordinateur. Un programme lancé sous votre compte utilisateur pourrait l'y lire sans {method} : le code PIN protège donc davantage. Le code PIN continue de fonctionner.", + "os_failed": "Cela n'a pas fonctionné. Saisissez plutôt votre code PIN.", + "lock_title": "{app} est verrouillé", + "locking": "Verrouillage…", + "pin_prompt": "Saisissez votre code PIN pour continuer.", + "unlock": "Déverrouiller", + "unlocking": "Déverrouillage…", + "wrong_pin": "Ce code PIN n'est pas correct.", + "unlock_failed": "Le déverrouillage n'a pas fonctionné. Réessayez.", + "blocked_later": "Trop d'essais. Réessayez plus tard.", + "forgot": "Code PIN oublié ?", + "forgot_title": "Supprimer les données locales et se déconnecter ?", + "forgot_body": "Un code PIN oublié ne peut pas être récupéré. Cela supprime les e-mails gardés sur cet appareil et déconnecte tous les comptes, pour que vous puissiez vous reconnecter. Vos e-mails sur le serveur restent. Les messages encore en attente d'envoi sur cet appareil sont perdus.", + "forgot_confirm": "Supprimer et se déconnecter", + "unlock_in_main": "Déverrouillez dans la fenêtre principale.", + "open_main": "Aller à la fenêtre principale", + "setup_required_title": "Votre organisation exige un verrouillage de l'app", + "setup_required_body": "Choisissez un code PIN. Il vous faudra pour ouvrir {app} après le verrouillage.", + "draft_saved_notice": "Ce que vous écriviez a été enregistré dans les brouillons avant le verrouillage.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minute} other {# minutes}}", + "blocked_seconds": "Trop d'essais. Réessayez dans {count, plural, one {# seconde} other {# secondes}}.", + "blocked_minutes": "Trop d'essais. Réessayez dans {count, plural, one {# minute} other {# minutes}}." + }, "desktop_sign_in": { "browser_button": "Se connecter avec le navigateur", "browser_waiting": "Terminez la connexion dans votre navigateur", diff --git a/locales/he/common.json b/locales/he/common.json index 8ca0148b4..851cf6085 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6561,6 +6561,71 @@ "copy_failed": "לא ניתן היה להעתיק את הקובץ." } }, + "appLock": { + "section_title": "נעילת אפליקציה", + "section_desc": "מבקשת קוד PIN אחרי זמן ללא שימוש, בכל חלון וכרטיסייה. בזמן הנעילה הדואר במכשיר הזה נשאר מוצפן ולאפליקציה אין מפתח אליו.", + "enable_label": "נעילה עם קוד PIN", + "enable_desc": "כבוי כברירת מחדל. קוד ה-PIN נשאר במכשיר הזה.", + "enable_body": "בחרו קוד PIN. תצטרכו אותו בכל פעם שהאפליקציה ננעלת, ואחרי טעינה מחדש או הפעלה חדשה.", + "set_pin_title": "בחירת קוד PIN", + "change_pin": "שינוי קוד PIN…", + "change_pin_title": "שינוי קוד PIN", + "pin_label": "קוד PIN", + "pin_current": "קוד PIN נוכחי", + "pin_new": "קוד PIN חדש", + "pin_repeat": "חזרה על קוד PIN", + "pin_hint": "לפחות 6 תווים. אפשר גם אותיות, וקוד ארוך יותר קשה יותר לניחוש.", + "pin_mismatch": "שני הקודים אינם זהים.", + "pin_recovery": "אם תשכחו אותו, הדרך היחידה היא להסיר את הנתונים מהמכשיר הזה ולהיכנס שוב. הדואר שלכם בשרת נשאר. הודעות שעדיין ממתינות לשליחה במכשיר הזה יאבדו.", + "save": "שמירה", + "cancel": "ביטול", + "save_failed": "זה לא הצליח. נסו שוב.", + "timeout_label": "נעילה אחרי", + "timeout_desc": "זמן ללא שימוש בכל חלון או כרטיסייה.", + "lock_now": "נעילה עכשיו", + "lock_now_desc": "נועל מיד את כל החלונות והכרטיסיות.", + "disable_title": "לכבות את נעילת האפליקציה?", + "disable_body": "הזינו את קוד ה-PIN כדי לכבות אותה.", + "disable_confirm": "כיבוי", + "passkey_label": "מפתח גישה", + "passkey_desc": "פתיחה עם מפתח גישה או מפתח אבטחה שיכול להגן על המפתח. לא כולם יכולים, וקוד ה-PIN ממשיך לעבוד.", + "passkey_add": "הוספת מפתח גישה…", + "passkey_add_title": "הוספת מפתח גישה", + "passkey_remove": "הסרה", + "passkey_added": "מפתח הגישה נוסף.", + "passkey_no_prf": "מפתח הגישה הזה לא יכול להגן על המפתח. קוד ה-PIN ממשיך לעבוד.", + "passkey_failed": "מפתח הגישה לא עבד. הזינו את קוד ה-PIN.", + "use_passkey": "שימוש במפתח גישה", + "use_hello": "שימוש ב-Windows Hello", + "use_touchid": "שימוש ב-Touch ID", + "os_add": "הפעלה…", + "os_add_title": "שימוש ב-{method}", + "os_remove": "כיבוי", + "os_desc": "{method} היא בדיקה של האפליקציה. אחרי אישור, האפליקציה קוראת את המפתח מהאחסון המוצפן של המחשב הזה. תוכנה שרצה תחת חשבון המשתמש שלכם יכולה לקרוא אותו שם גם בלי {method}, ולכן קוד ה-PIN מגן יותר. קוד ה-PIN ממשיך לעבוד.", + "os_failed": "זה לא הצליח. הזינו את קוד ה-PIN.", + "lock_title": "{app} נעולה", + "locking": "נועל…", + "pin_prompt": "הזינו את קוד ה-PIN כדי להמשיך.", + "unlock": "פתיחה", + "unlocking": "פותח…", + "wrong_pin": "קוד ה-PIN הזה שגוי.", + "unlock_failed": "הפתיחה לא הצליחה. נסו שוב.", + "blocked_later": "יותר מדי ניסיונות. נסו מאוחר יותר.", + "forgot": "שכחתם את קוד ה-PIN?", + "forgot_title": "להסיר נתונים מקומיים ולהתנתק?", + "forgot_body": "אי אפשר לשחזר קוד PIN שנשכח. פעולה זו מסירה את הדואר שנשמר במכשיר הזה ומנתקת את כל החשבונות, כדי שתוכלו להיכנס שוב. הדואר שלכם בשרת נשאר. הודעות שעדיין ממתינות לשליחה במכשיר הזה יאבדו.", + "forgot_confirm": "הסרה והתנתקות", + "unlock_in_main": "פתחו את הנעילה בחלון הראשי.", + "open_main": "מעבר לחלון הראשי", + "setup_required_title": "הארגון שלכם דורש נעילת אפליקציה", + "setup_required_body": "בחרו קוד PIN. תצטרכו אותו כדי לפתוח את {app} אחרי הנעילה.", + "draft_saved_notice": "מה שכתבתם נשמר בטיוטות לפני הנעילה.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {דקה אחת} two {שתי דקות} other {# דקות}}", + "blocked_seconds": "יותר מדי ניסיונות. נסו שוב בעוד {count, plural, one {שנייה אחת} two {שתי שניות} other {# שניות}}.", + "blocked_minutes": "יותר מדי ניסיונות. נסו שוב בעוד {count, plural, one {דקה אחת} two {שתי דקות} other {# דקות}}." + }, "desktop_sign_in": { "browser_button": "כניסה באמצעות הדפדפן", "browser_waiting": "יש להשלים את הכניסה בדפדפן", diff --git a/locales/hu/common.json b/locales/hu/common.json index 0459a57b5..e7a53cba0 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "A fájlt nem sikerült másolni." } }, + "appLock": { + "section_title": "Alkalmazászár", + "section_desc": "Használat nélküli idő után PIN-kódot kér, minden ablakban és lapon. Amíg zárolva van, az eszközön lévő levelek titkosítva maradnak, és az alkalmazásnak nincs hozzájuk kulcsa.", + "enable_label": "Zárolás PIN-kóddal", + "enable_desc": "Alapból ki van kapcsolva. A PIN-kód ezen az eszközön marad.", + "enable_body": "Válasszon PIN-kódot. Minden zároláskor, valamint újratöltés vagy új indítás után szükség lesz rá.", + "set_pin_title": "PIN-kód választása", + "change_pin": "PIN-kód módosítása…", + "change_pin_title": "A PIN-kód módosítása", + "pin_label": "PIN-kód", + "pin_current": "Jelenlegi PIN-kód", + "pin_new": "Új PIN-kód", + "pin_repeat": "PIN-kód ismét", + "pin_hint": "Legalább 6 karakter. Betűk is jók, és a hosszabb PIN-kódot nehezebb kitalálni.", + "pin_mismatch": "A két PIN-kód nem egyezik.", + "pin_recovery": "Ha elfelejti, az egyetlen megoldás az eszközön lévő adatok eltávolítása és az újbóli bejelentkezés. A szerveren lévő levelei megmaradnak. Az ezen az eszközön küldésre váró üzenetek elvesznek.", + "save": "Mentés", + "cancel": "Mégse", + "save_failed": "Ez nem sikerült. Próbálja újra.", + "timeout_label": "Zárolás ennyi után", + "timeout_desc": "Használat nélküli idő bármelyik ablakban vagy lapon.", + "lock_now": "Zárolás most", + "lock_now_desc": "Azonnal zárolja az összes ablakot és lapot.", + "disable_title": "Kikapcsolja az alkalmazászárat?", + "disable_body": "A kikapcsoláshoz írja be a PIN-kódját.", + "disable_confirm": "Kikapcsolás", + "passkey_label": "Jelkulcs", + "passkey_desc": "Feloldás olyan jelkulccsal vagy biztonsági kulccsal, amely védeni tudja a kulcsot. Nem mindegyik tudja, és a PIN-kód továbbra is működik.", + "passkey_add": "Jelkulcs hozzáadása…", + "passkey_add_title": "Jelkulcs hozzáadása", + "passkey_remove": "Eltávolítás", + "passkey_added": "A jelkulcs hozzáadva.", + "passkey_no_prf": "Ez a jelkulcs nem tudja védeni a kulcsot. A PIN-kód továbbra is működik.", + "passkey_failed": "A jelkulcs nem működött. Írja be a PIN-kódját.", + "use_passkey": "Jelkulcs használata", + "use_hello": "Windows Hello használata", + "use_touchid": "Touch ID használata", + "os_add": "Bekapcsolás…", + "os_add_title": "{method} használata", + "os_remove": "Kikapcsolás", + "os_desc": "A(z) {method} az alkalmazás ellenőrzése. Igen után az alkalmazás kiolvassa a kulcsot a számítógép titkosított tárolójából. Egy az Ön felhasználói fiókjában futó program enélkül is kiolvashatná onnan, ezért a PIN-kód többet véd. A PIN-kód továbbra is működik.", + "os_failed": "Ez nem sikerült. Írja be a PIN-kódját.", + "lock_title": "A(z) {app} zárolva van", + "locking": "Zárolás…", + "pin_prompt": "A folytatáshoz írja be a PIN-kódját.", + "unlock": "Feloldás", + "unlocking": "Feloldás…", + "wrong_pin": "Ez a PIN-kód nem helyes.", + "unlock_failed": "A feloldás nem sikerült. Próbálja újra.", + "blocked_later": "Túl sok próbálkozás. Próbálja később.", + "forgot": "Elfelejtette a PIN-kódot?", + "forgot_title": "Eltávolítja a helyi adatokat és kijelentkezik?", + "forgot_body": "Az elfelejtett PIN-kód nem állítható vissza. Ez eltávolítja az eszközön tárolt leveleket és minden fiókot kijelentkeztet, hogy újra be tudjon jelentkezni. A szerveren lévő levelei megmaradnak. Az ezen az eszközön küldésre váró üzenetek elvesznek.", + "forgot_confirm": "Eltávolítás és kijelentkezés", + "unlock_in_main": "Oldja fel a főablakban.", + "open_main": "Ugrás a főablakra", + "setup_required_title": "A szervezete alkalmazászárat követel meg", + "setup_required_body": "Válasszon PIN-kódot. A(z) {app} megnyitásához kell majd zárolás után.", + "draft_saved_notice": "Amit írt, zárolás előtt a Piszkozatok közé mentődött.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# perc} other {# perc}}", + "blocked_seconds": "Túl sok próbálkozás. Próbálja újra {count, plural, one {# másodperc} other {# másodperc}} múlva.", + "blocked_minutes": "Túl sok próbálkozás. Próbálja újra {count, plural, one {# perc} other {# perc}} múlva." + }, "desktop_sign_in": { "browser_button": "Bejelentkezés böngészővel", "browser_waiting": "Fejezze be a bejelentkezést a böngészőben", diff --git a/locales/it/common.json b/locales/it/common.json index 95db2212c..f3d65219d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Impossibile copiare il file." } }, + "appLock": { + "section_title": "Blocco dell'app", + "section_desc": "Chiede un PIN dopo un periodo senza utilizzo, in ogni finestra e scheda. Finché è bloccata, la posta su questo dispositivo resta cifrata e l'app non ha la chiave.", + "enable_label": "Blocca con un PIN", + "enable_desc": "Disattivato per impostazione predefinita. Il PIN resta su questo dispositivo.", + "enable_body": "Scegli un PIN. Ti servirà ogni volta che l'app si blocca, e dopo un ricaricamento o un nuovo avvio.", + "set_pin_title": "Scegli un PIN", + "change_pin": "Cambia PIN…", + "change_pin_title": "Cambia il PIN", + "pin_label": "PIN", + "pin_current": "PIN attuale", + "pin_new": "Nuovo PIN", + "pin_repeat": "Ripeti il PIN", + "pin_hint": "Almeno 6 caratteri. Vanno bene anche le lettere, e un PIN più lungo è più difficile da indovinare.", + "pin_mismatch": "I due PIN non coincidono.", + "pin_recovery": "Se lo dimentichi, l'unica strada è rimuovere i dati da questo dispositivo e accedere di nuovo. La tua posta sul server resta. I messaggi ancora in attesa di invio su questo dispositivo vanno persi.", + "save": "Salva", + "cancel": "Annulla", + "save_failed": "Non ha funzionato. Riprova.", + "timeout_label": "Blocca dopo", + "timeout_desc": "Tempo senza utilizzo in qualsiasi finestra o scheda.", + "lock_now": "Blocca ora", + "lock_now_desc": "Blocca subito tutte le finestre e le schede.", + "disable_title": "Disattivare il blocco?", + "disable_body": "Inserisci il PIN per disattivarlo.", + "disable_confirm": "Disattiva", + "passkey_label": "Passkey", + "passkey_desc": "Sblocca con una passkey o una chiave di sicurezza in grado di proteggere la chiave. Non tutte lo sono, e il PIN continua a funzionare.", + "passkey_add": "Aggiungi una passkey…", + "passkey_add_title": "Aggiungi una passkey", + "passkey_remove": "Rimuovi", + "passkey_added": "Passkey aggiunta.", + "passkey_no_prf": "Questa passkey non può proteggere la chiave. Il PIN continua a funzionare.", + "passkey_failed": "La passkey non ha funzionato. Inserisci il PIN.", + "use_passkey": "Usa una passkey", + "use_hello": "Usa Windows Hello", + "use_touchid": "Usa Touch ID", + "os_add": "Attiva…", + "os_add_title": "Usa {method}", + "os_remove": "Disattiva", + "os_desc": "{method} è un controllo fatto dall'app. Dopo un sì, l'app legge la chiave dall'archivio cifrato di questo computer. Un programma avviato con il tuo account utente potrebbe leggerla lì senza {method}, quindi il PIN protegge di più. Il PIN continua a funzionare.", + "os_failed": "Non ha funzionato. Inserisci il PIN.", + "lock_title": "{app} è bloccata", + "locking": "Blocco in corso…", + "pin_prompt": "Inserisci il PIN per continuare.", + "unlock": "Sblocca", + "unlocking": "Sblocco in corso…", + "wrong_pin": "Questo PIN non è corretto.", + "unlock_failed": "Lo sblocco non ha funzionato. Riprova.", + "blocked_later": "Troppi tentativi. Riprova più tardi.", + "forgot": "PIN dimenticato?", + "forgot_title": "Rimuovere i dati locali e uscire?", + "forgot_body": "Un PIN dimenticato non si può recuperare. Questo rimuove la posta salvata su questo dispositivo e disconnette tutti gli account, così puoi accedere di nuovo. La tua posta sul server resta. I messaggi ancora in attesa di invio su questo dispositivo vanno persi.", + "forgot_confirm": "Rimuovi ed esci", + "unlock_in_main": "Sblocca nella finestra principale.", + "open_main": "Vai alla finestra principale", + "setup_required_title": "La tua organizzazione richiede un blocco dell'app", + "setup_required_body": "Scegli un PIN. Ti servirà per aprire {app} dopo il blocco.", + "draft_saved_notice": "Quello che stavi scrivendo è stato salvato nelle bozze prima del blocco.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuto} other {# minuti}}", + "blocked_seconds": "Troppi tentativi. Riprova tra {count, plural, one {# secondo} other {# secondi}}.", + "blocked_minutes": "Troppi tentativi. Riprova tra {count, plural, one {# minuto} other {# minuti}}." + }, "desktop_sign_in": { "browser_button": "Accedi con il browser", "browser_waiting": "Completa l'accesso nel browser", diff --git a/locales/ja/common.json b/locales/ja/common.json index 78070a2f8..f29230c00 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "ファイルをコピーできませんでした。" } }, + "appLock": { + "section_title": "アプリのロック", + "section_desc": "一定時間使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中、このデバイスのメールは暗号化されたままで、アプリには鍵がありません。", + "enable_label": "PIN でロック", + "enable_desc": "既定ではオフです。PIN はこのデバイスにだけ保存されます。", + "enable_body": "PIN を選んでください。アプリがロックされるたび、また再読み込みや再起動の後に必要です。", + "set_pin_title": "PIN を選択", + "change_pin": "PIN を変更…", + "change_pin_title": "PIN を変更", + "pin_label": "PIN", + "pin_current": "現在の PIN", + "pin_new": "新しい PIN", + "pin_repeat": "PIN をもう一度", + "pin_hint": "6 文字以上。文字も使えます。長い PIN ほど推測されにくくなります。", + "pin_mismatch": "2 つの PIN が一致しません。", + "pin_recovery": "忘れた場合は、このデバイスのデータを削除してサインインし直すしかありません。サーバー上のメールは残ります。このデバイスで送信待ちのメッセージは失われます。", + "save": "保存", + "cancel": "キャンセル", + "save_failed": "うまくいきませんでした。もう一度お試しください。", + "timeout_label": "ロックまでの時間", + "timeout_desc": "どのウィンドウやタブでも使っていない時間です。", + "lock_now": "今すぐロック", + "lock_now_desc": "すべてのウィンドウとタブをすぐにロックします。", + "disable_title": "アプリのロックをオフにしますか?", + "disable_body": "オフにするには PIN を入力してください。", + "disable_confirm": "オフにする", + "passkey_label": "パスキー", + "passkey_desc": "鍵を保護できるパスキーやセキュリティキーでロックを解除します。すべてが対応しているわけではなく、PIN は引き続き使えます。", + "passkey_add": "パスキーを追加…", + "passkey_add_title": "パスキーを追加", + "passkey_remove": "削除", + "passkey_added": "パスキーを追加しました。", + "passkey_no_prf": "このパスキーでは鍵を保護できません。PIN は引き続き使えます。", + "passkey_failed": "パスキーが機能しませんでした。PIN を入力してください。", + "use_passkey": "パスキーを使う", + "use_hello": "Windows Hello を使う", + "use_touchid": "Touch ID を使う", + "os_add": "オンにする…", + "os_add_title": "{method} を使う", + "os_remove": "オフにする", + "os_desc": "{method} はアプリが行う確認です。確認後、アプリはこのコンピューターの暗号化ストレージから鍵を読み取ります。あなたのユーザーアカウントで動くプログラムは {method} なしでも読み取れるため、PIN のほうが強く保護します。PIN は引き続き使えます。", + "os_failed": "うまくいきませんでした。PIN を入力してください。", + "lock_title": "{app} はロックされています", + "locking": "ロックしています…", + "pin_prompt": "続けるには PIN を入力してください。", + "unlock": "ロック解除", + "unlocking": "ロックを解除しています…", + "wrong_pin": "この PIN は正しくありません。", + "unlock_failed": "ロックを解除できませんでした。もう一度お試しください。", + "blocked_later": "試行回数が多すぎます。後でもう一度お試しください。", + "forgot": "PIN を忘れましたか?", + "forgot_title": "ローカルデータを削除してサインアウトしますか?", + "forgot_body": "忘れた PIN は復元できません。このデバイスに保存されたメールを削除し、すべてのアカウントをサインアウトして、サインインし直せるようにします。サーバー上のメールは残ります。このデバイスで送信待ちのメッセージは失われます。", + "forgot_confirm": "削除してサインアウト", + "unlock_in_main": "メインウィンドウでロックを解除してください。", + "open_main": "メインウィンドウへ", + "setup_required_title": "組織がアプリのロックを必須にしています", + "setup_required_body": "PIN を選んでください。ロック後に {app} を開くときに必要です。", + "draft_saved_notice": "書いていた内容はロックの前に下書きに保存しました。", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# 分}}", + "blocked_seconds": "試行回数が多すぎます。{count, plural, other {# 秒}}後にもう一度お試しください。", + "blocked_minutes": "試行回数が多すぎます。{count, plural, other {# 分}}後にもう一度お試しください。" + }, "desktop_sign_in": { "browser_button": "ブラウザでサインイン", "browser_waiting": "ブラウザでサインインを完了してください", diff --git a/locales/ko/common.json b/locales/ko/common.json index 96a87153f..8470bf4a9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "파일을 복사할 수 없습니다." } }, + "appLock": { + "section_title": "앱 잠금", + "section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 이 기기의 메일은 암호화된 상태로 유지되며 앱에는 키가 없습니다.", + "enable_label": "PIN으로 잠금", + "enable_desc": "기본적으로 꺼져 있습니다. PIN은 이 기기에만 남습니다.", + "enable_body": "PIN을 선택하세요. 앱이 잠길 때마다, 그리고 다시 로드하거나 새로 시작한 후에 필요합니다.", + "set_pin_title": "PIN 선택", + "change_pin": "PIN 변경…", + "change_pin_title": "PIN 변경", + "pin_label": "PIN", + "pin_current": "현재 PIN", + "pin_new": "새 PIN", + "pin_repeat": "PIN 다시 입력", + "pin_hint": "6자 이상. 문자도 사용할 수 있으며 PIN이 길수록 추측하기 어렵습니다.", + "pin_mismatch": "두 PIN이 같지 않습니다.", + "pin_recovery": "잊어버리면 이 기기의 데이터를 제거하고 다시 로그인하는 방법밖에 없습니다. 서버의 메일은 그대로 남습니다. 이 기기에서 아직 전송을 기다리는 메시지는 사라집니다.", + "save": "저장", + "cancel": "취소", + "save_failed": "실패했습니다. 다시 시도하세요.", + "timeout_label": "잠금 시간", + "timeout_desc": "창이나 탭 어디에서도 사용하지 않은 시간입니다.", + "lock_now": "지금 잠금", + "lock_now_desc": "모든 창과 탭을 즉시 잠급니다.", + "disable_title": "앱 잠금을 끄시겠습니까?", + "disable_body": "끄려면 PIN을 입력하세요.", + "disable_confirm": "끄기", + "passkey_label": "패스키", + "passkey_desc": "키를 보호할 수 있는 패스키나 보안 키로 잠금을 해제합니다. 모두 가능한 것은 아니며 PIN은 계속 작동합니다.", + "passkey_add": "패스키 추가…", + "passkey_add_title": "패스키 추가", + "passkey_remove": "제거", + "passkey_added": "패스키를 추가했습니다.", + "passkey_no_prf": "이 패스키는 키를 보호할 수 없습니다. PIN은 계속 작동합니다.", + "passkey_failed": "패스키가 작동하지 않았습니다. PIN을 입력하세요.", + "use_passkey": "패스키 사용", + "use_hello": "Windows Hello 사용", + "use_touchid": "Touch ID 사용", + "os_add": "켜기…", + "os_add_title": "{method} 사용", + "os_remove": "끄기", + "os_desc": "{method}은(는) 앱이 하는 확인입니다. 확인되면 앱이 이 컴퓨터의 암호화된 저장소에서 키를 읽습니다. 사용자 계정으로 실행되는 프로그램은 {method} 없이도 읽을 수 있으므로 PIN이 더 강하게 보호합니다. PIN은 계속 작동합니다.", + "os_failed": "실패했습니다. PIN을 입력하세요.", + "lock_title": "{app}이(가) 잠겨 있습니다", + "locking": "잠그는 중…", + "pin_prompt": "계속하려면 PIN을 입력하세요.", + "unlock": "잠금 해제", + "unlocking": "잠금 해제 중…", + "wrong_pin": "PIN이 올바르지 않습니다.", + "unlock_failed": "잠금을 해제하지 못했습니다. 다시 시도하세요.", + "blocked_later": "시도 횟수가 너무 많습니다. 나중에 다시 시도하세요.", + "forgot": "PIN을 잊으셨나요?", + "forgot_title": "로컬 데이터를 제거하고 로그아웃하시겠습니까?", + "forgot_body": "잊어버린 PIN은 복구할 수 없습니다. 이 기기에 보관된 메일을 제거하고 모든 계정을 로그아웃하여 다시 로그인할 수 있게 합니다. 서버의 메일은 그대로 남습니다. 이 기기에서 아직 전송을 기다리는 메시지는 사라집니다.", + "forgot_confirm": "제거 후 로그아웃", + "unlock_in_main": "기본 창에서 잠금을 해제하세요.", + "open_main": "기본 창으로 이동", + "setup_required_title": "조직에서 앱 잠금을 요구합니다", + "setup_required_body": "PIN을 선택하세요. 잠긴 후 {app}을(를) 열 때 필요합니다.", + "draft_saved_notice": "작성 중이던 내용은 잠기기 전에 임시 보관함에 저장되었습니다.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {#분}}", + "blocked_seconds": "시도 횟수가 너무 많습니다. {count, plural, other {#초}} 후에 다시 시도하세요.", + "blocked_minutes": "시도 횟수가 너무 많습니다. {count, plural, other {#분}} 후에 다시 시도하세요." + }, "desktop_sign_in": { "browser_button": "브라우저로 로그인", "browser_waiting": "브라우저에서 로그인을 마치세요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 1ca156d61..ba8d362d5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Failu neizdevās nokopēt." } }, + "appLock": { + "section_title": "Lietotnes bloķēšana", + "section_desc": "Pēc laika bez lietošanas prasa PIN kodu katrā logā un cilnē. Kamēr lietotne ir bloķēta, e-pasts šajā ierīcē paliek šifrēts, un lietotnei nav tā atslēgas.", + "enable_label": "Bloķēt ar PIN kodu", + "enable_desc": "Pēc noklusējuma izslēgts. PIN kods paliek šajā ierīcē.", + "enable_body": "Izvēlieties PIN kodu. Tas būs vajadzīgs katru reizi, kad lietotne bloķējas, un pēc pārlādes vai jaunas palaišanas.", + "set_pin_title": "Izvēlieties PIN kodu", + "change_pin": "Mainīt PIN kodu…", + "change_pin_title": "Mainīt PIN kodu", + "pin_label": "PIN kods", + "pin_current": "Pašreizējais PIN kods", + "pin_new": "Jauns PIN kods", + "pin_repeat": "Atkārtojiet PIN kodu", + "pin_hint": "Vismaz 6 rakstzīmes. Der arī burti, un garāku PIN kodu ir grūtāk uzminēt.", + "pin_mismatch": "Abi PIN kodi nesakrīt.", + "pin_recovery": "Ja to aizmirsīsit, vienīgā iespēja ir noņemt datus šajā ierīcē un pieteikties vēlreiz. Jūsu e-pasts serverī paliek. Ziņojumi, kas šajā ierīcē vēl gaida sūtīšanu, tiek zaudēti.", + "save": "Saglabāt", + "cancel": "Atcelt", + "save_failed": "Tas neizdevās. Mēģiniet vēlreiz.", + "timeout_label": "Bloķēt pēc", + "timeout_desc": "Laiks bez lietošanas jebkurā logā vai cilnē.", + "lock_now": "Bloķēt tagad", + "lock_now_desc": "Uzreiz bloķē visus logus un cilnes.", + "disable_title": "Vai izslēgt lietotnes bloķēšanu?", + "disable_body": "Lai to izslēgtu, ievadiet PIN kodu.", + "disable_confirm": "Izslēgt", + "passkey_label": "Piekļuves atslēga", + "passkey_desc": "Atbloķējiet ar piekļuves vai drošības atslēgu, kas var aizsargāt atslēgu. Ne visas var, un PIN kods turpina darboties.", + "passkey_add": "Pievienot piekļuves atslēgu…", + "passkey_add_title": "Pievienot piekļuves atslēgu", + "passkey_remove": "Noņemt", + "passkey_added": "Piekļuves atslēga pievienota.", + "passkey_no_prf": "Šī piekļuves atslēga nevar aizsargāt atslēgu. PIN kods turpina darboties.", + "passkey_failed": "Piekļuves atslēga nedarbojās. Ievadiet PIN kodu.", + "use_passkey": "Izmantot piekļuves atslēgu", + "use_hello": "Izmantot Windows Hello", + "use_touchid": "Izmantot Touch ID", + "os_add": "Ieslēgt…", + "os_add_title": "Izmantot {method}", + "os_remove": "Izslēgt", + "os_desc": "{method} ir lietotnes veikta pārbaude. Pēc apstiprinājuma lietotne nolasa atslēgu no šī datora šifrētās krātuves. Programma, kas darbojas jūsu lietotāja kontā, varētu to nolasīt arī bez {method}, tāpēc PIN kods aizsargā vairāk. PIN kods turpina darboties.", + "os_failed": "Tas neizdevās. Ievadiet PIN kodu.", + "lock_title": "{app} ir bloķēta", + "locking": "Bloķē…", + "pin_prompt": "Lai turpinātu, ievadiet PIN kodu.", + "unlock": "Atbloķēt", + "unlocking": "Atbloķē…", + "wrong_pin": "Šis PIN kods nav pareizs.", + "unlock_failed": "Atbloķēšana neizdevās. Mēģiniet vēlreiz.", + "blocked_later": "Pārāk daudz mēģinājumu. Mēģiniet vēlāk.", + "forgot": "Aizmirsāt PIN kodu?", + "forgot_title": "Vai noņemt vietējos datus un izrakstīties?", + "forgot_body": "Aizmirstu PIN kodu nevar atgūt. Tas noņem šajā ierīcē glabāto e-pastu un izraksta visus kontus, lai varētu pieteikties vēlreiz. Jūsu e-pasts serverī paliek. Ziņojumi, kas šajā ierīcē vēl gaida sūtīšanu, tiek zaudēti.", + "forgot_confirm": "Noņemt un izrakstīties", + "unlock_in_main": "Atbloķējiet galvenajā logā.", + "open_main": "Uz galveno logu", + "setup_required_title": "Jūsu organizācija pieprasa lietotnes bloķēšanu", + "setup_required_body": "Izvēlieties PIN kodu. Tas būs vajadzīgs, lai atvērtu {app} pēc bloķēšanas.", + "draft_saved_notice": "Rakstītais pirms bloķēšanas tika saglabāts melnrakstos.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, zero {# minūtes} one {# minūtes} other {# minūtēm}}", + "blocked_seconds": "Pārāk daudz mēģinājumu. Mēģiniet vēlreiz pēc {count, plural, zero {# sekundēm} one {# sekundes} other {# sekundēm}}.", + "blocked_minutes": "Pārāk daudz mēģinājumu. Mēģiniet vēlreiz pēc {count, plural, zero {# minūtēm} one {# minūtes} other {# minūtēm}}." + }, "desktop_sign_in": { "browser_button": "Pierakstīties ar pārlūku", "browser_waiting": "Pabeidziet pierakstīšanos pārlūkā", diff --git a/locales/mn/common.json b/locales/mn/common.json index 52cc39b48..08d62630f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Файлыг хуулж чадсангүй." } }, + "appLock": { + "section_title": "Аппын түгжээ", + "section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН код асууна. Түгжээтэй үед энэ төхөөрөмжийн имэйл шифрлэгдсэн хэвээр байх бөгөөд аппад түлхүүр нь байхгүй.", + "enable_label": "ПИН кодоор түгжих", + "enable_desc": "Анхдагчаар унтраалттай. ПИН код энэ төхөөрөмж дээр үлдэнэ.", + "enable_body": "ПИН код сонгоно уу. Апп түгжигдэх бүрт, мөн дахин ачаалсан эсвэл шинээр эхлүүлсний дараа хэрэгтэй.", + "set_pin_title": "ПИН код сонгох", + "change_pin": "ПИН код солих…", + "change_pin_title": "ПИН код солих", + "pin_label": "ПИН код", + "pin_current": "Одоогийн ПИН код", + "pin_new": "Шинэ ПИН код", + "pin_repeat": "ПИН кодоо давтах", + "pin_hint": "Дор хаяж 6 тэмдэгт. Үсэг ч болно, урт ПИН кодыг таахад хэцүү.", + "pin_mismatch": "Хоёр ПИН код ижил биш байна.", + "pin_recovery": "Мартвал энэ төхөөрөмжийн өгөгдлийг устгаад дахин нэвтрэхээс өөр арга байхгүй. Сервер дээрх имэйл тань үлдэнэ. Энэ төхөөрөмж дээр илгээгдэхийг хүлээж буй мессежүүд алдагдана.", + "save": "Хадгалах", + "cancel": "Цуцлах", + "save_failed": "Болсонгүй. Дахин оролдоно уу.", + "timeout_label": "Түгжих хугацаа", + "timeout_desc": "Аль ч цонх эсвэл табад ашиглаагүй хугацаа.", + "lock_now": "Одоо түгжих", + "lock_now_desc": "Бүх цонх, табыг шууд түгжинэ.", + "disable_title": "Аппын түгжээг унтраах уу?", + "disable_body": "Унтраахын тулд ПИН кодоо оруулна уу.", + "disable_confirm": "Унтраах", + "passkey_label": "Нэвтрэх түлхүүр", + "passkey_desc": "Түлхүүрийг хамгаалж чадах нэвтрэх эсвэл аюулгүйн түлхүүрээр нээнэ. Бүгд чадахгүй бөгөөд ПИН код ажилласаар байна.", + "passkey_add": "Нэвтрэх түлхүүр нэмэх…", + "passkey_add_title": "Нэвтрэх түлхүүр нэмэх", + "passkey_remove": "Хасах", + "passkey_added": "Нэвтрэх түлхүүр нэмэгдлээ.", + "passkey_no_prf": "Энэ нэвтрэх түлхүүр түлхүүрийг хамгаалж чадахгүй. ПИН код ажилласаар байна.", + "passkey_failed": "Нэвтрэх түлхүүр ажилласангүй. ПИН кодоо оруулна уу.", + "use_passkey": "Нэвтрэх түлхүүр ашиглах", + "use_hello": "Windows Hello ашиглах", + "use_touchid": "Touch ID ашиглах", + "os_add": "Асаах…", + "os_add_title": "{method} ашиглах", + "os_remove": "Унтраах", + "os_desc": "{method} бол аппын хийдэг шалгалт юм. Зөвшөөрсний дараа апп түлхүүрийг энэ компьютерын шифрлэгдсэн санаас уншина. Таны хэрэглэгчийн бүртгэлд ажиллаж буй програм {method}-гүйгээр ч уншиж чадах тул ПИН код илүү хамгаална. ПИН код ажилласаар байна.", + "os_failed": "Болсонгүй. ПИН кодоо оруулна уу.", + "lock_title": "{app} түгжээтэй байна", + "locking": "Түгжиж байна…", + "pin_prompt": "Үргэлжлүүлэхийн тулд ПИН кодоо оруулна уу.", + "unlock": "Түгжээ тайлах", + "unlocking": "Түгжээ тайлж байна…", + "wrong_pin": "Энэ ПИН код буруу байна.", + "unlock_failed": "Түгжээ тайлж чадсангүй. Дахин оролдоно уу.", + "blocked_later": "Хэт олон оролдлого. Дараа дахин оролдоно уу.", + "forgot": "ПИН кодоо мартсан уу?", + "forgot_title": "Дотоод өгөгдлийг устгаад гарах уу?", + "forgot_body": "Мартсан ПИН кодыг сэргээх боломжгүй. Энэ нь энэ төхөөрөмжид хадгалсан имэйлийг устгаж, дахин нэвтрэх боломжтой болгохын тулд бүх бүртгэлээс гаргана. Сервер дээрх имэйл тань үлдэнэ. Энэ төхөөрөмж дээр илгээгдэхийг хүлээж буй мессежүүд алдагдана.", + "forgot_confirm": "Устгаад гарах", + "unlock_in_main": "Үндсэн цонхонд түгжээг тайлна уу.", + "open_main": "Үндсэн цонх руу очих", + "setup_required_title": "Танай байгууллага аппын түгжээ шаардаж байна", + "setup_required_body": "ПИН код сонгоно уу. Түгжигдсэний дараа {app}-ыг нээхэд хэрэгтэй.", + "draft_saved_notice": "Бичиж байсан зүйлсийг түгжихээс өмнө ноорогт хадгалсан.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# минут} other {# минут}}", + "blocked_seconds": "Хэт олон оролдлого. {count, plural, one {# секундын} other {# секундын}} дараа дахин оролдоно уу.", + "blocked_minutes": "Хэт олон оролдлого. {count, plural, one {# минутын} other {# минутын}} дараа дахин оролдоно уу." + }, "desktop_sign_in": { "browser_button": "Хөтчөөр нэвтрэх", "browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу", diff --git a/locales/nb/common.json b/locales/nb/common.json index 0d7083171..9a35dda25 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Filen kunne ikke kopieres." } }, + "appLock": { + "section_title": "Applås", + "section_desc": "Ber om en PIN-kode etter en tid uten bruk, i alle vinduer og faner. Mens den er låst, forblir e-post på denne enheten kryptert, og appen har ingen nøkkel til den.", + "enable_label": "Lås med en PIN-kode", + "enable_desc": "Av som standard. PIN-koden blir på denne enheten.", + "enable_body": "Velg en PIN-kode. Du trenger den hver gang appen låses, og etter en ny lasting eller ny start.", + "set_pin_title": "Velg en PIN-kode", + "change_pin": "Endre PIN-kode…", + "change_pin_title": "Endre PIN-koden", + "pin_label": "PIN-kode", + "pin_current": "Nåværende PIN-kode", + "pin_new": "Ny PIN-kode", + "pin_repeat": "Gjenta PIN-koden", + "pin_hint": "Minst 6 tegn. Bokstaver går også, og en lengre PIN-kode er vanskeligere å gjette.", + "pin_mismatch": "De to PIN-kodene er ikke like.", + "pin_recovery": "Hvis du glemmer den, er eneste utvei å fjerne dataene på denne enheten og logge på igjen. E-posten din på serveren blir værende. Meldinger som fortsatt venter på sending fra denne enheten, går tapt.", + "save": "Lagre", + "cancel": "Avbryt", + "save_failed": "Det virket ikke. Prøv igjen.", + "timeout_label": "Lås etter", + "timeout_desc": "Tid uten bruk i et vindu eller en fane.", + "lock_now": "Lås nå", + "lock_now_desc": "Låser alle vinduer og faner med en gang.", + "disable_title": "Slå av applåsen?", + "disable_body": "Skriv inn PIN-koden for å slå den av.", + "disable_confirm": "Slå av", + "passkey_label": "Tilgangsnøkkel", + "passkey_desc": "Lås opp med en tilgangsnøkkel eller sikkerhetsnøkkel som kan beskytte nøkkelen. Ikke alle kan det, og PIN-koden virker fortsatt.", + "passkey_add": "Legg til en tilgangsnøkkel…", + "passkey_add_title": "Legg til en tilgangsnøkkel", + "passkey_remove": "Fjern", + "passkey_added": "Tilgangsnøkkelen er lagt til.", + "passkey_no_prf": "Denne tilgangsnøkkelen kan ikke beskytte nøkkelen. PIN-koden virker fortsatt.", + "passkey_failed": "Tilgangsnøkkelen virket ikke. Skriv inn PIN-koden.", + "use_passkey": "Bruk en tilgangsnøkkel", + "use_hello": "Bruk Windows Hello", + "use_touchid": "Bruk Touch ID", + "os_add": "Slå på…", + "os_add_title": "Bruk {method}", + "os_remove": "Slå av", + "os_desc": "{method} er en kontroll appen gjør. Etter et ja leser appen nøkkelen fra datamaskinens krypterte lager. Et program under brukerkontoen din kan lese den der uten {method}, så PIN-koden beskytter mer. PIN-koden virker fortsatt.", + "os_failed": "Det virket ikke. Skriv inn PIN-koden.", + "lock_title": "{app} er låst", + "locking": "Låser…", + "pin_prompt": "Skriv inn PIN-koden for å fortsette.", + "unlock": "Lås opp", + "unlocking": "Låser opp…", + "wrong_pin": "Den PIN-koden er ikke riktig.", + "unlock_failed": "Opplåsing virket ikke. Prøv igjen.", + "blocked_later": "For mange forsøk. Prøv igjen senere.", + "forgot": "Glemt PIN-koden?", + "forgot_title": "Fjerne lokale data og logge ut?", + "forgot_body": "En glemt PIN-kode kan ikke gjenopprettes. Dette fjerner e-post lagret på denne enheten og logger ut alle kontoer, slik at du kan logge på igjen. E-posten din på serveren blir værende. Meldinger som fortsatt venter på sending fra denne enheten, går tapt.", + "forgot_confirm": "Fjern og logg ut", + "unlock_in_main": "Lås opp i hovedvinduet.", + "open_main": "Gå til hovedvinduet", + "setup_required_title": "Organisasjonen din krever en applås", + "setup_required_body": "Velg en PIN-kode. Du trenger den for å åpne {app} når den er låst.", + "draft_saved_notice": "Det du skrev, ble lagret i Utkast før appen ble låst.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minutt} other {# minutter}}", + "blocked_seconds": "For mange forsøk. Prøv igjen om {count, plural, one {# sekund} other {# sekunder}}.", + "blocked_minutes": "For mange forsøk. Prøv igjen om {count, plural, one {# minutt} other {# minutter}}." + }, "desktop_sign_in": { "browser_button": "Logg på med nettleseren", "browser_waiting": "Fullfør påloggingen i nettleseren", diff --git a/locales/nl/common.json b/locales/nl/common.json index b0e75ada3..f079b04e7 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Het bestand kon niet worden gekopieerd." } }, + "appLock": { + "section_title": "App-vergrendeling", + "section_desc": "Vraagt na een tijd zonder gebruik om een pincode, in elk venster en tabblad. Zolang de app vergrendeld is, blijft de mail op dit apparaat versleuteld en heeft de app er geen sleutel voor.", + "enable_label": "Vergrendelen met een pincode", + "enable_desc": "Standaard uit. De pincode blijft op dit apparaat.", + "enable_body": "Kies een pincode. Die heeft u nodig telkens als de app vergrendelt, en na herladen of opnieuw starten.", + "set_pin_title": "Kies een pincode", + "change_pin": "Pincode wijzigen…", + "change_pin_title": "De pincode wijzigen", + "pin_label": "Pincode", + "pin_current": "Huidige pincode", + "pin_new": "Nieuwe pincode", + "pin_repeat": "Herhaal de pincode", + "pin_hint": "Minstens 6 tekens. Letters mogen ook, en een langere pincode is moeilijker te raden.", + "pin_mismatch": "De twee pincodes zijn niet gelijk.", + "pin_recovery": "Als u hem vergeet, kunt u alleen de gegevens op dit apparaat verwijderen en opnieuw inloggen. Uw mail op de server blijft. Berichten die op dit apparaat nog op verzending wachten, gaan verloren.", + "save": "Opslaan", + "cancel": "Annuleren", + "save_failed": "Dat is niet gelukt. Probeer het opnieuw.", + "timeout_label": "Vergrendelen na", + "timeout_desc": "Tijd zonder gebruik in een venster of tabblad.", + "lock_now": "Nu vergrendelen", + "lock_now_desc": "Vergrendelt alle vensters en tabbladen meteen.", + "disable_title": "App-vergrendeling uitzetten?", + "disable_body": "Voer uw pincode in om die uit te zetten.", + "disable_confirm": "Uitzetten", + "passkey_label": "Passkey", + "passkey_desc": "Ontgrendel met een passkey of beveiligingssleutel die de sleutel kan beschermen. Dat kunnen ze niet allemaal, en de pincode blijft werken.", + "passkey_add": "Passkey toevoegen…", + "passkey_add_title": "Passkey toevoegen", + "passkey_remove": "Verwijderen", + "passkey_added": "Passkey toegevoegd.", + "passkey_no_prf": "Deze passkey kan de sleutel niet beschermen. Uw pincode blijft werken.", + "passkey_failed": "De passkey werkte niet. Voer uw pincode in.", + "use_passkey": "Passkey gebruiken", + "use_hello": "Windows Hello gebruiken", + "use_touchid": "Touch ID gebruiken", + "os_add": "Aanzetten…", + "os_add_title": "{method} gebruiken", + "os_remove": "Uitzetten", + "os_desc": "{method} is een controle door de app. Na een ja leest de app de sleutel uit de versleutelde opslag van deze computer. Een programma onder uw gebruikersaccount kan hem daar ook zonder {method} lezen, dus de pincode beschermt meer. De pincode blijft werken.", + "os_failed": "Dat is niet gelukt. Voer uw pincode in.", + "lock_title": "{app} is vergrendeld", + "locking": "Vergrendelen…", + "pin_prompt": "Voer uw pincode in om verder te gaan.", + "unlock": "Ontgrendelen", + "unlocking": "Ontgrendelen…", + "wrong_pin": "Die pincode klopt niet.", + "unlock_failed": "Ontgrendelen is niet gelukt. Probeer het opnieuw.", + "blocked_later": "Te veel pogingen. Probeer het later opnieuw.", + "forgot": "Pincode vergeten?", + "forgot_title": "Lokale gegevens verwijderen en uitloggen?", + "forgot_body": "Een vergeten pincode kan niet worden hersteld. Dit verwijdert de mail op dit apparaat en logt alle accounts uit, zodat u opnieuw kunt inloggen. Uw mail op de server blijft. Berichten die op dit apparaat nog op verzending wachten, gaan verloren.", + "forgot_confirm": "Verwijderen en uitloggen", + "unlock_in_main": "Ontgrendel in het hoofdvenster.", + "open_main": "Naar het hoofdvenster", + "setup_required_title": "Uw organisatie vereist een app-vergrendeling", + "setup_required_body": "Kies een pincode. Die heeft u nodig om {app} na het vergrendelen te openen.", + "draft_saved_notice": "Wat u aan het schrijven was, is voor het vergrendelen opgeslagen in Concepten.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuut} other {# minuten}}", + "blocked_seconds": "Te veel pogingen. Probeer het over {count, plural, one {# seconde} other {# seconden}} opnieuw.", + "blocked_minutes": "Te veel pogingen. Probeer het over {count, plural, one {# minuut} other {# minuten}} opnieuw." + }, "desktop_sign_in": { "browser_button": "Inloggen met je browser", "browser_waiting": "Rond het inloggen af in je browser", diff --git a/locales/pl/common.json b/locales/pl/common.json index eb4f9f8fc..b057fd16c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Nie udało się skopiować pliku." } }, + "appLock": { + "section_title": "Blokada aplikacji", + "section_desc": "Prosi o PIN po czasie bezczynności, w każdym oknie i na każdej karcie. Gdy aplikacja jest zablokowana, poczta na tym urządzeniu pozostaje zaszyfrowana, a aplikacja nie ma do niej klucza.", + "enable_label": "Blokuj kodem PIN", + "enable_desc": "Domyślnie wyłączone. PIN pozostaje na tym urządzeniu.", + "enable_body": "Wybierz PIN. Będzie potrzebny za każdym razem, gdy aplikacja się zablokuje, oraz po przeładowaniu lub ponownym uruchomieniu.", + "set_pin_title": "Wybierz PIN", + "change_pin": "Zmień PIN…", + "change_pin_title": "Zmień PIN", + "pin_label": "PIN", + "pin_current": "Obecny PIN", + "pin_new": "Nowy PIN", + "pin_repeat": "Powtórz PIN", + "pin_hint": "Co najmniej 6 znaków. Można też użyć liter, a dłuższy PIN trudniej odgadnąć.", + "pin_mismatch": "Te dwa kody PIN nie są takie same.", + "pin_recovery": "Jeśli go zapomnisz, jedynym wyjściem jest usunięcie danych z tego urządzenia i ponowne zalogowanie. Twoja poczta na serwerze zostaje. Wiadomości czekające na wysłanie na tym urządzeniu przepadną.", + "save": "Zapisz", + "cancel": "Anuluj", + "save_failed": "To się nie udało. Spróbuj ponownie.", + "timeout_label": "Blokuj po", + "timeout_desc": "Czas bez użycia w dowolnym oknie lub karcie.", + "lock_now": "Zablokuj teraz", + "lock_now_desc": "Blokuje od razu wszystkie okna i karty.", + "disable_title": "Wyłączyć blokadę aplikacji?", + "disable_body": "Wpisz PIN, aby ją wyłączyć.", + "disable_confirm": "Wyłącz", + "passkey_label": "Klucz dostępu", + "passkey_desc": "Odblokuj kluczem dostępu lub kluczem bezpieczeństwa, który potrafi chronić klucz. Nie każdy to potrafi, a PIN nadal działa.", + "passkey_add": "Dodaj klucz dostępu…", + "passkey_add_title": "Dodaj klucz dostępu", + "passkey_remove": "Usuń", + "passkey_added": "Dodano klucz dostępu.", + "passkey_no_prf": "Ten klucz dostępu nie może chronić klucza. PIN nadal działa.", + "passkey_failed": "Klucz dostępu nie zadziałał. Wpisz PIN.", + "use_passkey": "Użyj klucza dostępu", + "use_hello": "Użyj Windows Hello", + "use_touchid": "Użyj Touch ID", + "os_add": "Włącz…", + "os_add_title": "Użyj: {method}", + "os_remove": "Wyłącz", + "os_desc": "{method} to sprawdzenie wykonywane przez aplikację. Po potwierdzeniu aplikacja odczytuje klucz z zaszyfrowanego magazynu tego komputera. Program działający na Twoim koncie użytkownika mógłby go tam odczytać także bez tego, więc PIN chroni lepiej. PIN nadal działa.", + "os_failed": "To się nie udało. Wpisz PIN.", + "lock_title": "{app} jest zablokowana", + "locking": "Blokowanie…", + "pin_prompt": "Wpisz PIN, aby kontynuować.", + "unlock": "Odblokuj", + "unlocking": "Odblokowywanie…", + "wrong_pin": "Ten PIN jest nieprawidłowy.", + "unlock_failed": "Odblokowanie się nie udało. Spróbuj ponownie.", + "blocked_later": "Zbyt wiele prób. Spróbuj później.", + "forgot": "Nie pamiętasz kodu PIN?", + "forgot_title": "Usunąć dane lokalne i wylogować?", + "forgot_body": "Zapomnianego kodu PIN nie da się odzyskać. To usunie pocztę zapisaną na tym urządzeniu i wyloguje wszystkie konta, aby można było zalogować się ponownie. Twoja poczta na serwerze zostaje. Wiadomości czekające na wysłanie na tym urządzeniu przepadną.", + "forgot_confirm": "Usuń i wyloguj", + "unlock_in_main": "Odblokuj w oknie głównym.", + "open_main": "Przejdź do okna głównego", + "setup_required_title": "Twoja organizacja wymaga blokady aplikacji", + "setup_required_body": "Wybierz PIN. Będzie potrzebny do otwarcia {app} po zablokowaniu.", + "draft_saved_notice": "To, co pisano, zapisano w wersjach roboczych przed zablokowaniem.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuta} few {# minuty} many {# minut} other {# minuty}}", + "blocked_seconds": "Zbyt wiele prób. Spróbuj za {count, plural, one {# sekundę} few {# sekundy} many {# sekund} other {# sekundy}}.", + "blocked_minutes": "Zbyt wiele prób. Spróbuj za {count, plural, one {# minutę} few {# minuty} many {# minut} other {# minuty}}." + }, "desktop_sign_in": { "browser_button": "Zaloguj się w przeglądarce", "browser_waiting": "Dokończ logowanie w przeglądarce", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5235f5317..7f5008dbe 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Não foi possível copiar o ficheiro." } }, + "appLock": { + "section_title": "Bloqueio da app", + "section_desc": "Pede um PIN após um tempo sem uso, em todas as janelas e separadores. Enquanto está bloqueada, o correio neste dispositivo fica cifrado e a app não tem a chave.", + "enable_label": "Bloquear com um PIN", + "enable_desc": "Desativado por predefinição. O PIN fica neste dispositivo.", + "enable_body": "Escolha um PIN. Vai precisar dele sempre que a app bloquear, e depois de recarregar ou iniciar de novo.", + "set_pin_title": "Escolher um PIN", + "change_pin": "Alterar PIN…", + "change_pin_title": "Alterar o PIN", + "pin_label": "PIN", + "pin_current": "PIN atual", + "pin_new": "Novo PIN", + "pin_repeat": "Repetir o PIN", + "pin_hint": "Pelo menos 6 caracteres. Também pode usar letras, e um PIN mais longo é mais difícil de adivinhar.", + "pin_mismatch": "Os dois PIN não são iguais.", + "pin_recovery": "Se o esquecer, a única saída é remover os dados deste dispositivo e voltar a iniciar sessão. O seu correio no servidor mantém-se. As mensagens ainda à espera de envio neste dispositivo perdem-se.", + "save": "Guardar", + "cancel": "Cancelar", + "save_failed": "Não funcionou. Tente novamente.", + "timeout_label": "Bloquear após", + "timeout_desc": "Tempo sem uso em qualquer janela ou separador.", + "lock_now": "Bloquear agora", + "lock_now_desc": "Bloqueia todas as janelas e separadores de imediato.", + "disable_title": "Desativar o bloqueio?", + "disable_body": "Introduza o seu PIN para o desativar.", + "disable_confirm": "Desativar", + "passkey_label": "Chave de acesso", + "passkey_desc": "Desbloqueie com uma chave de acesso ou de segurança capaz de proteger a chave. Nem todas conseguem, e o PIN continua a funcionar.", + "passkey_add": "Adicionar uma chave de acesso…", + "passkey_add_title": "Adicionar uma chave de acesso", + "passkey_remove": "Remover", + "passkey_added": "Chave de acesso adicionada.", + "passkey_no_prf": "Esta chave de acesso não consegue proteger a chave. O seu PIN continua a funcionar.", + "passkey_failed": "A chave de acesso não funcionou. Introduza o seu PIN.", + "use_passkey": "Usar uma chave de acesso", + "use_hello": "Usar o Windows Hello", + "use_touchid": "Usar o Touch ID", + "os_add": "Ativar…", + "os_add_title": "Usar {method}", + "os_remove": "Desativar", + "os_desc": "{method} é uma verificação feita pela app. Depois de um sim, a app lê a chave do armazenamento cifrado deste computador. Um programa a correr na sua conta de utilizador poderia lê-la ali sem {method}, por isso o PIN protege mais. O PIN continua a funcionar.", + "os_failed": "Não funcionou. Introduza o seu PIN.", + "lock_title": "{app} está bloqueada", + "locking": "A bloquear…", + "pin_prompt": "Introduza o seu PIN para continuar.", + "unlock": "Desbloquear", + "unlocking": "A desbloquear…", + "wrong_pin": "Esse PIN não está certo.", + "unlock_failed": "O desbloqueio não funcionou. Tente novamente.", + "blocked_later": "Demasiadas tentativas. Tente mais tarde.", + "forgot": "Esqueceu-se do PIN?", + "forgot_title": "Remover os dados locais e terminar sessão?", + "forgot_body": "Um PIN esquecido não pode ser recuperado. Isto remove o correio guardado neste dispositivo e termina a sessão de todas as contas, para que possa entrar de novo. O seu correio no servidor mantém-se. As mensagens ainda à espera de envio neste dispositivo perdem-se.", + "forgot_confirm": "Remover e terminar sessão", + "unlock_in_main": "Desbloqueie na janela principal.", + "open_main": "Ir para a janela principal", + "setup_required_title": "A sua organização exige um bloqueio da app", + "setup_required_body": "Escolha um PIN. Vai precisar dele para abrir {app} depois de bloquear.", + "draft_saved_notice": "O que estava a escrever foi guardado nos Rascunhos antes do bloqueio.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuto} other {# minutos}}", + "blocked_seconds": "Demasiadas tentativas. Tente novamente daqui a {count, plural, one {# segundo} other {# segundos}}.", + "blocked_minutes": "Demasiadas tentativas. Tente novamente daqui a {count, plural, one {# minuto} other {# minutos}}." + }, "desktop_sign_in": { "browser_button": "Entrar com o navegador", "browser_waiting": "Termine de entrar no navegador", diff --git a/locales/ro/common.json b/locales/ro/common.json index 5076823a5..e0bff076a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Fișierul nu a putut fi copiat." } }, + "appLock": { + "section_title": "Blocarea aplicației", + "section_desc": "Cere un PIN după un timp fără utilizare, în fiecare fereastră și filă. Cât timp e blocată, e-mailurile de pe acest dispozitiv rămân criptate și aplicația nu are cheia lor.", + "enable_label": "Blochează cu un PIN", + "enable_desc": "Dezactivat implicit. PIN-ul rămâne pe acest dispozitiv.", + "enable_body": "Alegeți un PIN. Veți avea nevoie de el de fiecare dată când aplicația se blochează, și după o reîncărcare sau o repornire.", + "set_pin_title": "Alegeți un PIN", + "change_pin": "Schimbați PIN-ul…", + "change_pin_title": "Schimbați PIN-ul", + "pin_label": "PIN", + "pin_current": "PIN-ul actual", + "pin_new": "PIN nou", + "pin_repeat": "Repetați PIN-ul", + "pin_hint": "Cel puțin 6 caractere. Merg și literele, iar un PIN mai lung e mai greu de ghicit.", + "pin_mismatch": "Cele două PIN-uri nu sunt la fel.", + "pin_recovery": "Dacă îl uitați, singura soluție este să eliminați datele de pe acest dispozitiv și să vă conectați din nou. E-mailurile de pe server rămân. Mesajele care încă așteaptă trimiterea pe acest dispozitiv se pierd.", + "save": "Salvați", + "cancel": "Anulați", + "save_failed": "Nu a mers. Încercați din nou.", + "timeout_label": "Blochează după", + "timeout_desc": "Timp fără utilizare în orice fereastră sau filă.", + "lock_now": "Blochează acum", + "lock_now_desc": "Blochează imediat toate ferestrele și filele.", + "disable_title": "Dezactivați blocarea?", + "disable_body": "Introduceți PIN-ul pentru a o dezactiva.", + "disable_confirm": "Dezactivați", + "passkey_label": "Cheie de acces", + "passkey_desc": "Deblocați cu o cheie de acces sau de securitate care poate proteja cheia. Nu toate pot, iar PIN-ul funcționează în continuare.", + "passkey_add": "Adăugați o cheie de acces…", + "passkey_add_title": "Adăugați o cheie de acces", + "passkey_remove": "Eliminați", + "passkey_added": "Cheia de acces a fost adăugată.", + "passkey_no_prf": "Această cheie de acces nu poate proteja cheia. PIN-ul funcționează în continuare.", + "passkey_failed": "Cheia de acces nu a mers. Introduceți PIN-ul.", + "use_passkey": "Folosiți o cheie de acces", + "use_hello": "Folosiți Windows Hello", + "use_touchid": "Folosiți Touch ID", + "os_add": "Activați…", + "os_add_title": "Folosiți {method}", + "os_remove": "Dezactivați", + "os_desc": "{method} este o verificare făcută de aplicație. După un da, aplicația citește cheia din spațiul criptat al acestui computer. Un program care rulează sub contul dvs. ar putea-o citi acolo fără {method}, deci PIN-ul protejează mai mult. PIN-ul funcționează în continuare.", + "os_failed": "Nu a mers. Introduceți PIN-ul.", + "lock_title": "{app} este blocată", + "locking": "Se blochează…", + "pin_prompt": "Introduceți PIN-ul pentru a continua.", + "unlock": "Deblocați", + "unlocking": "Se deblochează…", + "wrong_pin": "Acest PIN nu este corect.", + "unlock_failed": "Deblocarea nu a mers. Încercați din nou.", + "blocked_later": "Prea multe încercări. Încercați mai târziu.", + "forgot": "Ați uitat PIN-ul?", + "forgot_title": "Eliminați datele locale și vă deconectați?", + "forgot_body": "Un PIN uitat nu poate fi recuperat. Astfel se elimină e-mailurile păstrate pe acest dispozitiv și toate conturile sunt deconectate, ca să vă puteți conecta din nou. E-mailurile de pe server rămân. Mesajele care încă așteaptă trimiterea pe acest dispozitiv se pierd.", + "forgot_confirm": "Eliminați și deconectați", + "unlock_in_main": "Deblocați în fereastra principală.", + "open_main": "Mergeți la fereastra principală", + "setup_required_title": "Organizația dvs. cere o blocare a aplicației", + "setup_required_body": "Alegeți un PIN. Veți avea nevoie de el pentru a deschide {app} după blocare.", + "draft_saved_notice": "Ce scriați a fost salvat în Ciorne înainte de blocare.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minut} few {# minute} other {# de minute}}", + "blocked_seconds": "Prea multe încercări. Încercați din nou peste {count, plural, one {# secundă} few {# secunde} other {# de secunde}}.", + "blocked_minutes": "Prea multe încercări. Încercați din nou peste {count, plural, one {# minut} few {# minute} other {# de minute}}." + }, "desktop_sign_in": { "browser_button": "Conectează-te cu browserul", "browser_waiting": "Finalizează conectarea în browser", diff --git a/locales/ru/common.json b/locales/ru/common.json index 05ce8b7b8..45ed49d6f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Не удалось скопировать файл." } }, + "appLock": { + "section_title": "Блокировка приложения", + "section_desc": "Запрашивает PIN-код после периода бездействия, в каждом окне и вкладке. Пока приложение заблокировано, почта на этом устройстве остаётся зашифрованной, и у приложения нет ключа к ней.", + "enable_label": "Блокировать PIN-кодом", + "enable_desc": "По умолчанию выключено. PIN-код остаётся на этом устройстве.", + "enable_body": "Выберите PIN-код. Он понадобится при каждой блокировке приложения, а также после перезагрузки или нового запуска.", + "set_pin_title": "Выберите PIN-код", + "change_pin": "Изменить PIN-код…", + "change_pin_title": "Изменить PIN-код", + "pin_label": "PIN-код", + "pin_current": "Текущий PIN-код", + "pin_new": "Новый PIN-код", + "pin_repeat": "Повторите PIN-код", + "pin_hint": "Не менее 6 символов. Можно использовать буквы, а длинный PIN-код труднее угадать.", + "pin_mismatch": "PIN-коды не совпадают.", + "pin_recovery": "Если вы его забудете, останется только удалить данные на этом устройстве и войти снова. Ваша почта на сервере сохранится. Сообщения, ожидающие отправки на этом устройстве, будут потеряны.", + "save": "Сохранить", + "cancel": "Отмена", + "save_failed": "Не получилось. Попробуйте ещё раз.", + "timeout_label": "Блокировать через", + "timeout_desc": "Время бездействия в любом окне или вкладке.", + "lock_now": "Заблокировать сейчас", + "lock_now_desc": "Сразу блокирует все окна и вкладки.", + "disable_title": "Выключить блокировку приложения?", + "disable_body": "Введите PIN-код, чтобы выключить её.", + "disable_confirm": "Выключить", + "passkey_label": "Ключ доступа", + "passkey_desc": "Разблокировка ключом доступа или ключом безопасности, способным защитить ключ. Не все на это способны, а PIN-код продолжает работать.", + "passkey_add": "Добавить ключ доступа…", + "passkey_add_title": "Добавить ключ доступа", + "passkey_remove": "Удалить", + "passkey_added": "Ключ доступа добавлен.", + "passkey_no_prf": "Этот ключ доступа не может защитить ключ. PIN-код продолжает работать.", + "passkey_failed": "Ключ доступа не сработал. Введите PIN-код.", + "use_passkey": "Использовать ключ доступа", + "use_hello": "Использовать Windows Hello", + "use_touchid": "Использовать Touch ID", + "os_add": "Включить…", + "os_add_title": "Использовать {method}", + "os_remove": "Выключить", + "os_desc": "{method} — это проверка, которую выполняет приложение. После подтверждения приложение читает ключ из зашифрованного хранилища этого компьютера. Программа, запущенная под вашей учётной записью, могла бы прочитать его там и без {method}, поэтому PIN-код защищает лучше. PIN-код продолжает работать.", + "os_failed": "Не получилось. Введите PIN-код.", + "lock_title": "{app}: заблокировано", + "locking": "Блокировка…", + "pin_prompt": "Введите PIN-код, чтобы продолжить.", + "unlock": "Разблокировать", + "unlocking": "Разблокировка…", + "wrong_pin": "Этот PIN-код неверный.", + "unlock_failed": "Не удалось разблокировать. Попробуйте ещё раз.", + "blocked_later": "Слишком много попыток. Попробуйте позже.", + "forgot": "Забыли PIN-код?", + "forgot_title": "Удалить локальные данные и выйти?", + "forgot_body": "Забытый PIN-код нельзя восстановить. Будет удалена почта, хранящаяся на этом устройстве, и выполнен выход из всех учётных записей, чтобы вы могли войти снова. Ваша почта на сервере сохранится. Сообщения, ожидающие отправки на этом устройстве, будут потеряны.", + "forgot_confirm": "Удалить и выйти", + "unlock_in_main": "Разблокируйте в главном окне.", + "open_main": "Перейти в главное окно", + "setup_required_title": "Ваша организация требует блокировку приложения", + "setup_required_body": "Выберите PIN-код. Он понадобится, чтобы открыть {app} после блокировки.", + "draft_saved_notice": "Написанное было сохранено в черновиках перед блокировкой.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# минута} few {# минуты} many {# минут} other {# минуты}}", + "blocked_seconds": "Слишком много попыток. Повторите через {count, plural, one {# секунду} few {# секунды} many {# секунд} other {# секунды}}.", + "blocked_minutes": "Слишком много попыток. Повторите через {count, plural, one {# минуту} few {# минуты} many {# минут} other {# минуты}}." + }, "desktop_sign_in": { "browser_button": "Войти через браузер", "browser_waiting": "Завершите вход в браузере", diff --git a/locales/sk/common.json b/locales/sk/common.json index 392915139..0f201b60d 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Súbor sa nepodarilo skopírovať." } }, + "appLock": { + "section_title": "Zámok aplikácie", + "section_desc": "Po čase bez používania žiada o PIN, v každom okne a na každej karte. Kým je zamknuté, pošta na tomto zariadení zostáva šifrovaná a aplikácia k nej nemá kľúč.", + "enable_label": "Zamykať kódom PIN", + "enable_desc": "V predvolenom stave vypnuté. PIN zostáva na tomto zariadení.", + "enable_body": "Zvoľte PIN. Budete ho potrebovať pri každom zamknutí aplikácie a po opätovnom načítaní alebo novom spustení.", + "set_pin_title": "Zvoľte PIN", + "change_pin": "Zmeniť PIN…", + "change_pin_title": "Zmeniť PIN", + "pin_label": "PIN", + "pin_current": "Súčasný PIN", + "pin_new": "Nový PIN", + "pin_repeat": "Zopakujte PIN", + "pin_hint": "Aspoň 6 znakov. Fungujú aj písmená a dlhší PIN sa ťažšie uhádne.", + "pin_mismatch": "Oba kódy PIN nie sú rovnaké.", + "pin_recovery": "Ak ho zabudnete, jedinou cestou je odstrániť údaje z tohto zariadenia a znova sa prihlásiť. Vaša pošta na serveri zostáva. Správy čakajúce na odoslanie na tomto zariadení sa stratia.", + "save": "Uložiť", + "cancel": "Zrušiť", + "save_failed": "Nepodarilo sa to. Skúste to znova.", + "timeout_label": "Zamknúť po", + "timeout_desc": "Čas bez používania v ktoromkoľvek okne alebo karte.", + "lock_now": "Zamknúť teraz", + "lock_now_desc": "Zamkne hneď všetky okná a karty.", + "disable_title": "Vypnúť zámok aplikácie?", + "disable_body": "Na vypnutie zadajte PIN.", + "disable_confirm": "Vypnúť", + "passkey_label": "Prístupový kľúč", + "passkey_desc": "Odomknite prístupovým alebo bezpečnostným kľúčom, ktorý vie chrániť kľúč. Nie všetky to vedia a PIN ďalej funguje.", + "passkey_add": "Pridať prístupový kľúč…", + "passkey_add_title": "Pridať prístupový kľúč", + "passkey_remove": "Odstrániť", + "passkey_added": "Prístupový kľúč bol pridaný.", + "passkey_no_prf": "Tento prístupový kľúč nemôže chrániť kľúč. PIN ďalej funguje.", + "passkey_failed": "Prístupový kľúč nefungoval. Zadajte PIN.", + "use_passkey": "Použiť prístupový kľúč", + "use_hello": "Použiť Windows Hello", + "use_touchid": "Použiť Touch ID", + "os_add": "Zapnúť…", + "os_add_title": "Použiť {method}", + "os_remove": "Vypnúť", + "os_desc": "{method} je kontrola, ktorú robí aplikácia. Po potvrdení aplikácia prečíta kľúč zo šifrovaného úložiska tohto počítača. Program bežiaci pod vaším používateľským účtom by ho tam mohol prečítať aj bez toho, takže PIN chráni viac. PIN ďalej funguje.", + "os_failed": "Nepodarilo sa to. Zadajte PIN.", + "lock_title": "{app} je zamknutá", + "locking": "Zamyká sa…", + "pin_prompt": "Pokračujte zadaním kódu PIN.", + "unlock": "Odomknúť", + "unlocking": "Odomyká sa…", + "wrong_pin": "Tento PIN nie je správny.", + "unlock_failed": "Odomknutie sa nepodarilo. Skúste to znova.", + "blocked_later": "Príliš veľa pokusov. Skúste to neskôr.", + "forgot": "Zabudli ste PIN?", + "forgot_title": "Odstrániť miestne údaje a odhlásiť sa?", + "forgot_body": "Zabudnutý PIN sa nedá obnoviť. Odstráni sa tým pošta uložená na tomto zariadení a odhlásia sa všetky účty, aby ste sa mohli znova prihlásiť. Vaša pošta na serveri zostáva. Správy čakajúce na odoslanie na tomto zariadení sa stratia.", + "forgot_confirm": "Odstrániť a odhlásiť", + "unlock_in_main": "Odomknite v hlavnom okne.", + "open_main": "Prejsť do hlavného okna", + "setup_required_title": "Vaša organizácia vyžaduje zámok aplikácie", + "setup_required_body": "Zvoľte PIN. Budete ho potrebovať na otvorenie {app} po zamknutí.", + "draft_saved_notice": "To, čo ste písali, sa pred zamknutím uložilo do konceptov.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minúta} few {# minúty} many {# minúty} other {# minút}}", + "blocked_seconds": "Príliš veľa pokusov. Skúste to o {count, plural, one {# sekundu} few {# sekundy} many {# sekundy} other {# sekúnd}}.", + "blocked_minutes": "Príliš veľa pokusov. Skúste to o {count, plural, one {# minútu} few {# minúty} many {# minúty} other {# minút}}." + }, "desktop_sign_in": { "browser_button": "Prihlásiť sa v prehliadači", "browser_waiting": "Dokončite prihlásenie v prehliadači", diff --git a/locales/th/common.json b/locales/th/common.json index 0c34470ff..b81114f0f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6568,6 +6568,71 @@ "copy_failed": "คัดลอกไฟล์ไม่ได้" } }, + "appLock": { + "section_title": "ล็อกแอป", + "section_desc": "ขอ PIN หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างล็อก อีเมลบนอุปกรณ์นี้ยังคงเข้ารหัสอยู่และแอปไม่มีกุญแจ", + "enable_label": "ล็อกด้วย PIN", + "enable_desc": "ปิดอยู่โดยค่าเริ่มต้น PIN จะอยู่บนอุปกรณ์นี้", + "enable_body": "เลือก PIN คุณต้องใช้ทุกครั้งที่แอปล็อก และหลังโหลดใหม่หรือเริ่มใหม่", + "set_pin_title": "เลือก PIN", + "change_pin": "เปลี่ยน PIN…", + "change_pin_title": "เปลี่ยน PIN", + "pin_label": "PIN", + "pin_current": "PIN ปัจจุบัน", + "pin_new": "PIN ใหม่", + "pin_repeat": "ใส่ PIN อีกครั้ง", + "pin_hint": "อย่างน้อย 6 ตัวอักษร ใช้ตัวอักษรได้ และ PIN ที่ยาวกว่าเดาได้ยากกว่า", + "pin_mismatch": "PIN ทั้งสองไม่ตรงกัน", + "pin_recovery": "หากลืม วิธีเดียวคือลบข้อมูลบนอุปกรณ์นี้แล้วลงชื่อเข้าใช้ใหม่ อีเมลบนเซิร์ฟเวอร์ยังอยู่ ข้อความที่ยังรอส่งบนอุปกรณ์นี้จะหายไป", + "save": "บันทึก", + "cancel": "ยกเลิก", + "save_failed": "ไม่สำเร็จ ลองอีกครั้ง", + "timeout_label": "ล็อกหลังจาก", + "timeout_desc": "เวลาที่ไม่ได้ใช้งานในหน้าต่างหรือแท็บใดก็ตาม", + "lock_now": "ล็อกเดี๋ยวนี้", + "lock_now_desc": "ล็อกทุกหน้าต่างและแท็บทันที", + "disable_title": "ปิดการล็อกแอปหรือไม่", + "disable_body": "ใส่ PIN เพื่อปิด", + "disable_confirm": "ปิด", + "passkey_label": "พาสคีย์", + "passkey_desc": "ปลดล็อกด้วยพาสคีย์หรือคีย์ความปลอดภัยที่ปกป้องกุญแจได้ ไม่ใช่ทุกอันที่ทำได้ และ PIN ยังใช้ได้", + "passkey_add": "เพิ่มพาสคีย์…", + "passkey_add_title": "เพิ่มพาสคีย์", + "passkey_remove": "ลบ", + "passkey_added": "เพิ่มพาสคีย์แล้ว", + "passkey_no_prf": "พาสคีย์นี้ปกป้องกุญแจไม่ได้ PIN ยังใช้ได้", + "passkey_failed": "พาสคีย์ใช้ไม่ได้ ใส่ PIN แทน", + "use_passkey": "ใช้พาสคีย์", + "use_hello": "ใช้ Windows Hello", + "use_touchid": "ใช้ Touch ID", + "os_add": "เปิด…", + "os_add_title": "ใช้ {method}", + "os_remove": "ปิด", + "os_desc": "{method} เป็นการตรวจสอบโดยแอป หลังจากยืนยัน แอปจะอ่านกุญแจจากที่เก็บแบบเข้ารหัสของคอมพิวเตอร์นี้ โปรแกรมที่ทำงานภายใต้บัญชีผู้ใช้ของคุณอาจอ่านได้โดยไม่ต้องใช้ {method} ดังนั้น PIN จึงปกป้องได้มากกว่า PIN ยังใช้ได้", + "os_failed": "ไม่สำเร็จ ใส่ PIN แทน", + "lock_title": "{app} ถูกล็อก", + "locking": "กำลังล็อก…", + "pin_prompt": "ใส่ PIN เพื่อดำเนินการต่อ", + "unlock": "ปลดล็อก", + "unlocking": "กำลังปลดล็อก…", + "wrong_pin": "PIN นี้ไม่ถูกต้อง", + "unlock_failed": "ปลดล็อกไม่สำเร็จ ลองอีกครั้ง", + "blocked_later": "ลองหลายครั้งเกินไป ลองอีกครั้งภายหลัง", + "forgot": "ลืม PIN หรือไม่", + "forgot_title": "ลบข้อมูลในเครื่องและออกจากระบบหรือไม่", + "forgot_body": "PIN ที่ลืมไม่สามารถกู้คืนได้ การดำเนินการนี้จะลบอีเมลที่เก็บบนอุปกรณ์นี้และออกจากระบบทุกบัญชี เพื่อให้คุณลงชื่อเข้าใช้ใหม่ได้ อีเมลบนเซิร์ฟเวอร์ยังอยู่ ข้อความที่ยังรอส่งบนอุปกรณ์นี้จะหายไป", + "forgot_confirm": "ลบและออกจากระบบ", + "unlock_in_main": "ปลดล็อกในหน้าต่างหลัก", + "open_main": "ไปที่หน้าต่างหลัก", + "setup_required_title": "องค์กรของคุณกำหนดให้ใช้การล็อกแอป", + "setup_required_body": "เลือก PIN คุณต้องใช้เพื่อเปิด {app} หลังจากล็อก", + "draft_saved_notice": "สิ่งที่คุณเขียนถูกบันทึกในแบบร่างก่อนล็อก", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# นาที}}", + "blocked_seconds": "ลองหลายครั้งเกินไป ลองอีกครั้งใน {count, plural, other {# วินาที}}", + "blocked_minutes": "ลองหลายครั้งเกินไป ลองอีกครั้งใน {count, plural, other {# นาที}}" + }, "desktop_sign_in": { "browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์", "browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์", diff --git a/locales/tr/common.json b/locales/tr/common.json index 5e5365633..28f0c7260 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Dosya kopyalanamadı." } }, + "appLock": { + "section_title": "Uygulama kilidi", + "section_desc": "Bir süre kullanılmadığında her pencerede ve sekmede PIN ister. Kilitliyken bu cihazdaki e-postalar şifreli kalır ve uygulamada anahtarı olmaz.", + "enable_label": "PIN ile kilitle", + "enable_desc": "Varsayılan olarak kapalı. PIN bu cihazda kalır.", + "enable_body": "Bir PIN seçin. Uygulama her kilitlendiğinde ve yeniden yükleme ya da yeni başlatma sonrasında gerekir.", + "set_pin_title": "PIN seçin", + "change_pin": "PIN'i değiştir…", + "change_pin_title": "PIN'i değiştir", + "pin_label": "PIN", + "pin_current": "Geçerli PIN", + "pin_new": "Yeni PIN", + "pin_repeat": "PIN'i tekrarlayın", + "pin_hint": "En az 6 karakter. Harfler de olur ve daha uzun bir PIN'i tahmin etmek daha zordur.", + "pin_mismatch": "İki PIN aynı değil.", + "pin_recovery": "Unutursanız tek çözüm bu cihazdaki verileri kaldırıp yeniden oturum açmaktır. Sunucudaki e-postalarınız kalır. Bu cihazda hâlâ gönderilmeyi bekleyen iletiler kaybolur.", + "save": "Kaydet", + "cancel": "İptal", + "save_failed": "Olmadı. Yeniden deneyin.", + "timeout_label": "Şu süre sonra kilitle", + "timeout_desc": "Herhangi bir pencerede veya sekmede kullanılmayan süre.", + "lock_now": "Şimdi kilitle", + "lock_now_desc": "Tüm pencereleri ve sekmeleri hemen kilitler.", + "disable_title": "Uygulama kilidi kapatılsın mı?", + "disable_body": "Kapatmak için PIN'inizi girin.", + "disable_confirm": "Kapat", + "passkey_label": "Geçiş anahtarı", + "passkey_desc": "Anahtarı koruyabilen bir geçiş anahtarı veya güvenlik anahtarıyla açın. Hepsi koruyamaz ve PIN çalışmaya devam eder.", + "passkey_add": "Geçiş anahtarı ekle…", + "passkey_add_title": "Geçiş anahtarı ekle", + "passkey_remove": "Kaldır", + "passkey_added": "Geçiş anahtarı eklendi.", + "passkey_no_prf": "Bu geçiş anahtarı anahtarı koruyamaz. PIN'iniz çalışmaya devam eder.", + "passkey_failed": "Geçiş anahtarı çalışmadı. PIN'inizi girin.", + "use_passkey": "Geçiş anahtarı kullan", + "use_hello": "Windows Hello kullan", + "use_touchid": "Touch ID kullan", + "os_add": "Aç…", + "os_add_title": "{method} kullan", + "os_remove": "Kapat", + "os_desc": "{method}, uygulamanın yaptığı bir denetimdir. Onaydan sonra uygulama anahtarı bu bilgisayarın şifreli deposundan okur. Kullanıcı hesabınızda çalışan bir program onu orada {method} olmadan da okuyabilir, bu yüzden PIN daha çok korur. PIN çalışmaya devam eder.", + "os_failed": "Olmadı. PIN'inizi girin.", + "lock_title": "{app} kilitli", + "locking": "Kilitleniyor…", + "pin_prompt": "Devam etmek için PIN'inizi girin.", + "unlock": "Kilidi aç", + "unlocking": "Kilit açılıyor…", + "wrong_pin": "Bu PIN doğru değil.", + "unlock_failed": "Kilit açılamadı. Yeniden deneyin.", + "blocked_later": "Çok fazla deneme. Daha sonra yeniden deneyin.", + "forgot": "PIN'inizi mi unuttunuz?", + "forgot_title": "Yerel veriler kaldırılıp oturum kapatılsın mı?", + "forgot_body": "Unutulan PIN kurtarılamaz. Bu işlem bu cihazda tutulan e-postaları kaldırır ve yeniden oturum açabilmeniz için tüm hesapların oturumunu kapatır. Sunucudaki e-postalarınız kalır. Bu cihazda hâlâ gönderilmeyi bekleyen iletiler kaybolur.", + "forgot_confirm": "Kaldır ve oturumu kapat", + "unlock_in_main": "Ana pencerede kilidi açın.", + "open_main": "Ana pencereye git", + "setup_required_title": "Kuruluşunuz uygulama kilidi istiyor", + "setup_required_body": "Bir PIN seçin. {app} kilitlendikten sonra açmak için gerekir.", + "draft_saved_notice": "Yazdıklarınız kilitlenmeden önce Taslaklar'a kaydedildi.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# dakika} other {# dakika}}", + "blocked_seconds": "Çok fazla deneme. {count, plural, one {# saniye} other {# saniye}} sonra yeniden deneyin.", + "blocked_minutes": "Çok fazla deneme. {count, plural, one {# dakika} other {# dakika}} sonra yeniden deneyin." + }, "desktop_sign_in": { "browser_button": "Tarayıcıyla oturum aç", "browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın", diff --git a/locales/uk/common.json b/locales/uk/common.json index 9d6aaed35..cb8f53185 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Не вдалося скопіювати файл." } }, + "appLock": { + "section_title": "Блокування застосунку", + "section_desc": "Запитує PIN-код після часу без використання, у кожному вікні й вкладці. Поки застосунок заблоковано, пошта на цьому пристрої залишається зашифрованою, і застосунок не має ключа до неї.", + "enable_label": "Блокувати PIN-кодом", + "enable_desc": "Типово вимкнено. PIN-код залишається на цьому пристрої.", + "enable_body": "Виберіть PIN-код. Він знадобиться щоразу, коли застосунок блокується, а також після перезавантаження чи нового запуску.", + "set_pin_title": "Виберіть PIN-код", + "change_pin": "Змінити PIN-код…", + "change_pin_title": "Змінити PIN-код", + "pin_label": "PIN-код", + "pin_current": "Поточний PIN-код", + "pin_new": "Новий PIN-код", + "pin_repeat": "Повторіть PIN-код", + "pin_hint": "Щонайменше 6 символів. Можна вводити літери, а довший PIN-код важче вгадати.", + "pin_mismatch": "PIN-коди не збігаються.", + "pin_recovery": "Якщо ви його забудете, залишиться лише вилучити дані на цьому пристрої й увійти знову. Ваша пошта на сервері збережеться. Повідомлення, що ще чекають надсилання на цьому пристрої, буде втрачено.", + "save": "Зберегти", + "cancel": "Скасувати", + "save_failed": "Не вдалося. Спробуйте ще раз.", + "timeout_label": "Блокувати через", + "timeout_desc": "Час без використання в будь-якому вікні чи вкладці.", + "lock_now": "Заблокувати зараз", + "lock_now_desc": "Одразу блокує всі вікна й вкладки.", + "disable_title": "Вимкнути блокування застосунку?", + "disable_body": "Введіть PIN-код, щоб вимкнути його.", + "disable_confirm": "Вимкнути", + "passkey_label": "Ключ доступу", + "passkey_desc": "Розблокування ключем доступу або ключем безпеки, що може захистити ключ. Не всі можуть, а PIN-код продовжує працювати.", + "passkey_add": "Додати ключ доступу…", + "passkey_add_title": "Додати ключ доступу", + "passkey_remove": "Вилучити", + "passkey_added": "Ключ доступу додано.", + "passkey_no_prf": "Цей ключ доступу не може захистити ключ. PIN-код продовжує працювати.", + "passkey_failed": "Ключ доступу не спрацював. Введіть PIN-код.", + "use_passkey": "Використати ключ доступу", + "use_hello": "Використати Windows Hello", + "use_touchid": "Використати Touch ID", + "os_add": "Увімкнути…", + "os_add_title": "Використати {method}", + "os_remove": "Вимкнути", + "os_desc": "{method} — це перевірка, яку виконує застосунок. Після підтвердження застосунок читає ключ із зашифрованого сховища цього комп'ютера. Програма під вашим обліковим записом могла б прочитати його там і без {method}, тому PIN-код захищає краще. PIN-код продовжує працювати.", + "os_failed": "Не вдалося. Введіть PIN-код.", + "lock_title": "{app}: заблоковано", + "locking": "Блокування…", + "pin_prompt": "Введіть PIN-код, щоб продовжити.", + "unlock": "Розблокувати", + "unlocking": "Розблокування…", + "wrong_pin": "Цей PIN-код неправильний.", + "unlock_failed": "Не вдалося розблокувати. Спробуйте ще раз.", + "blocked_later": "Забагато спроб. Спробуйте пізніше.", + "forgot": "Забули PIN-код?", + "forgot_title": "Вилучити локальні дані й вийти?", + "forgot_body": "Забутий PIN-код не можна відновити. Буде вилучено пошту, збережену на цьому пристрої, і виконано вихід з усіх облікових записів, щоб ви могли увійти знову. Ваша пошта на сервері збережеться. Повідомлення, що ще чекають надсилання на цьому пристрої, буде втрачено.", + "forgot_confirm": "Вилучити й вийти", + "unlock_in_main": "Розблокуйте в головному вікні.", + "open_main": "Перейти до головного вікна", + "setup_required_title": "Ваша організація вимагає блокування застосунку", + "setup_required_body": "Виберіть PIN-код. Він знадобиться, щоб відкрити {app} після блокування.", + "draft_saved_notice": "Написане було збережено в чернетках перед блокуванням.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# хвилина} few {# хвилини} many {# хвилин} other {# хвилини}}", + "blocked_seconds": "Забагато спроб. Повторіть через {count, plural, one {# секунду} few {# секунди} many {# секунд} other {# секунди}}.", + "blocked_minutes": "Забагато спроб. Повторіть через {count, plural, one {# хвилину} few {# хвилини} many {# хвилин} other {# хвилини}}." + }, "desktop_sign_in": { "browser_button": "Увійти через браузер", "browser_waiting": "Завершіть вхід у браузері", diff --git a/locales/vi/common.json b/locales/vi/common.json index 2076cdb65..c955ba865 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6572,6 +6572,71 @@ "copy_failed": "Không thể sao chép tệp." } }, + "appLock": { + "section_title": "Khóa ứng dụng", + "section_desc": "Yêu cầu mã PIN sau một thời gian không dùng, trong mọi cửa sổ và thẻ. Khi đang khóa, thư trên thiết bị này vẫn được mã hóa và ứng dụng không có khóa của chúng.", + "enable_label": "Khóa bằng mã PIN", + "enable_desc": "Mặc định tắt. Mã PIN chỉ ở trên thiết bị này.", + "enable_body": "Hãy chọn một mã PIN. Bạn cần nó mỗi khi ứng dụng khóa, và sau khi tải lại hoặc khởi động lại.", + "set_pin_title": "Chọn mã PIN", + "change_pin": "Đổi mã PIN…", + "change_pin_title": "Đổi mã PIN", + "pin_label": "Mã PIN", + "pin_current": "Mã PIN hiện tại", + "pin_new": "Mã PIN mới", + "pin_repeat": "Nhập lại mã PIN", + "pin_hint": "Ít nhất 6 ký tự. Có thể dùng cả chữ cái, và mã dài hơn sẽ khó đoán hơn.", + "pin_mismatch": "Hai mã PIN không giống nhau.", + "pin_recovery": "Nếu quên, cách duy nhất là xóa dữ liệu trên thiết bị này và đăng nhập lại. Thư của bạn trên máy chủ vẫn còn. Các thư đang chờ gửi trên thiết bị này sẽ bị mất.", + "save": "Lưu", + "cancel": "Hủy", + "save_failed": "Không thành công. Hãy thử lại.", + "timeout_label": "Khóa sau", + "timeout_desc": "Thời gian không dùng ở bất kỳ cửa sổ hoặc thẻ nào.", + "lock_now": "Khóa ngay", + "lock_now_desc": "Khóa ngay mọi cửa sổ và thẻ.", + "disable_title": "Tắt khóa ứng dụng?", + "disable_body": "Nhập mã PIN để tắt.", + "disable_confirm": "Tắt", + "passkey_label": "Khóa truy cập", + "passkey_desc": "Mở khóa bằng khóa truy cập hoặc khóa bảo mật có thể bảo vệ khóa. Không phải loại nào cũng làm được, và mã PIN vẫn dùng được.", + "passkey_add": "Thêm khóa truy cập…", + "passkey_add_title": "Thêm khóa truy cập", + "passkey_remove": "Gỡ bỏ", + "passkey_added": "Đã thêm khóa truy cập.", + "passkey_no_prf": "Khóa truy cập này không thể bảo vệ khóa. Mã PIN vẫn dùng được.", + "passkey_failed": "Khóa truy cập không hoạt động. Hãy nhập mã PIN.", + "use_passkey": "Dùng khóa truy cập", + "use_hello": "Dùng Windows Hello", + "use_touchid": "Dùng Touch ID", + "os_add": "Bật…", + "os_add_title": "Dùng {method}", + "os_remove": "Tắt", + "os_desc": "{method} là bước kiểm tra do ứng dụng thực hiện. Sau khi xác nhận, ứng dụng đọc khóa từ bộ nhớ mã hóa của máy tính này. Một chương trình chạy dưới tài khoản người dùng của bạn có thể đọc nó ở đó mà không cần {method}, nên mã PIN bảo vệ tốt hơn. Mã PIN vẫn dùng được.", + "os_failed": "Không thành công. Hãy nhập mã PIN.", + "lock_title": "{app} đang khóa", + "locking": "Đang khóa…", + "pin_prompt": "Nhập mã PIN để tiếp tục.", + "unlock": "Mở khóa", + "unlocking": "Đang mở khóa…", + "wrong_pin": "Mã PIN này không đúng.", + "unlock_failed": "Không mở khóa được. Hãy thử lại.", + "blocked_later": "Quá nhiều lần thử. Hãy thử lại sau.", + "forgot": "Quên mã PIN?", + "forgot_title": "Xóa dữ liệu cục bộ và đăng xuất?", + "forgot_body": "Mã PIN đã quên không thể khôi phục. Thao tác này xóa thư lưu trên thiết bị này và đăng xuất mọi tài khoản để bạn có thể đăng nhập lại. Thư của bạn trên máy chủ vẫn còn. Các thư đang chờ gửi trên thiết bị này sẽ bị mất.", + "forgot_confirm": "Xóa và đăng xuất", + "unlock_in_main": "Hãy mở khóa trong cửa sổ chính.", + "open_main": "Đến cửa sổ chính", + "setup_required_title": "Tổ chức của bạn yêu cầu khóa ứng dụng", + "setup_required_body": "Hãy chọn một mã PIN. Bạn cần nó để mở {app} sau khi khóa.", + "draft_saved_notice": "Nội dung bạn đang viết đã được lưu vào Thư nháp trước khi khóa.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# phút}}", + "blocked_seconds": "Quá nhiều lần thử. Hãy thử lại sau {count, plural, other {# giây}}.", + "blocked_minutes": "Quá nhiều lần thử. Hãy thử lại sau {count, plural, other {# phút}}." + }, "desktop_sign_in": { "browser_button": "Đăng nhập bằng trình duyệt", "browser_waiting": "Hoàn tất đăng nhập trong trình duyệt", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 53eefab4f..b26f4da00 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "無法複製檔案。" } }, + "appLock": { + "section_title": "應用程式鎖定", + "section_desc": "一段時間未使用後,在每個視窗和分頁中要求輸入 PIN。鎖定期間,此裝置上的郵件保持加密,應用程式沒有金鑰。", + "enable_label": "使用 PIN 鎖定", + "enable_desc": "預設關閉。PIN 只保存在此裝置上。", + "enable_body": "請選擇一個 PIN。每次應用程式鎖定時以及重新載入或重新啟動後都需要它。", + "set_pin_title": "選擇 PIN", + "change_pin": "變更 PIN…", + "change_pin_title": "變更 PIN", + "pin_label": "PIN", + "pin_current": "目前的 PIN", + "pin_new": "新 PIN", + "pin_repeat": "重複 PIN", + "pin_hint": "至少 6 個字元。也可以使用字母,較長的 PIN 更難猜到。", + "pin_mismatch": "兩個 PIN 不一致。", + "pin_recovery": "如果忘記了,唯一的辦法是移除此裝置上的資料並重新登入。伺服器上的郵件會保留。此裝置上仍在等待傳送的郵件將遺失。", + "save": "儲存", + "cancel": "取消", + "save_failed": "未成功。請再試一次。", + "timeout_label": "鎖定時間", + "timeout_desc": "任一視窗或分頁未使用的時間。", + "lock_now": "立即鎖定", + "lock_now_desc": "立即鎖定所有視窗和分頁。", + "disable_title": "關閉應用程式鎖定?", + "disable_body": "輸入 PIN 以將其關閉。", + "disable_confirm": "關閉", + "passkey_label": "密碼金鑰", + "passkey_desc": "使用能夠保護金鑰的密碼金鑰或安全金鑰解鎖。並非全部都支援,PIN 仍然可用。", + "passkey_add": "新增密碼金鑰…", + "passkey_add_title": "新增密碼金鑰", + "passkey_remove": "移除", + "passkey_added": "已新增密碼金鑰。", + "passkey_no_prf": "此密碼金鑰無法保護金鑰。PIN 仍然可用。", + "passkey_failed": "密碼金鑰無法運作。請輸入 PIN。", + "use_passkey": "使用密碼金鑰", + "use_hello": "使用 Windows Hello", + "use_touchid": "使用 Touch ID", + "os_add": "開啟…", + "os_add_title": "使用 {method}", + "os_remove": "關閉", + "os_desc": "{method} 是應用程式進行的檢查。確認後,應用程式從此電腦的加密儲存空間讀取金鑰。在您的使用者帳戶下執行的程式不需 {method} 也能讀取,因此 PIN 的保護更強。PIN 仍然可用。", + "os_failed": "未成功。請輸入 PIN。", + "lock_title": "{app} 已鎖定", + "locking": "正在鎖定…", + "pin_prompt": "輸入 PIN 以繼續。", + "unlock": "解鎖", + "unlocking": "正在解鎖…", + "wrong_pin": "此 PIN 不正確。", + "unlock_failed": "解鎖未成功。請再試一次。", + "blocked_later": "嘗試次數過多。請稍後再試。", + "forgot": "忘記了 PIN?", + "forgot_title": "移除本機資料並登出?", + "forgot_body": "忘記的 PIN 無法復原。這會移除此裝置上保存的郵件並登出所有帳戶,讓您可以重新登入。伺服器上的郵件會保留。此裝置上仍在等待傳送的郵件將遺失。", + "forgot_confirm": "移除並登出", + "unlock_in_main": "請在主視窗中解鎖。", + "open_main": "前往主視窗", + "setup_required_title": "您的組織要求使用應用程式鎖定", + "setup_required_body": "請選擇一個 PIN。鎖定後開啟 {app} 時需要它。", + "draft_saved_notice": "您正在撰寫的內容已在鎖定前儲存到草稿。", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# 分鐘}}", + "blocked_seconds": "嘗試次數過多。請在 {count, plural, other {# 秒}}後再試。", + "blocked_minutes": "嘗試次數過多。請在 {count, plural, other {# 分鐘}}後再試。" + }, "desktop_sign_in": { "browser_button": "使用瀏覽器登入", "browser_waiting": "請在瀏覽器中完成登入", diff --git a/locales/zh/common.json b/locales/zh/common.json index 770f679eb..f58b041f6 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "无法复制文件。" } }, + "appLock": { + "section_title": "应用锁", + "section_desc": "一段时间未使用后,在每个窗口和标签页中要求输入 PIN。锁定期间,此设备上的邮件保持加密,应用没有密钥。", + "enable_label": "使用 PIN 锁定", + "enable_desc": "默认关闭。PIN 只保存在此设备上。", + "enable_body": "请选择一个 PIN。每次应用锁定时以及重新加载或重新启动后都需要它。", + "set_pin_title": "选择 PIN", + "change_pin": "更改 PIN…", + "change_pin_title": "更改 PIN", + "pin_label": "PIN", + "pin_current": "当前 PIN", + "pin_new": "新 PIN", + "pin_repeat": "重复 PIN", + "pin_hint": "至少 6 个字符。也可以使用字母,较长的 PIN 更难猜到。", + "pin_mismatch": "两个 PIN 不一致。", + "pin_recovery": "如果忘记了,唯一的办法是删除此设备上的数据并重新登录。服务器上的邮件会保留。此设备上仍在等待发送的邮件将丢失。", + "save": "保存", + "cancel": "取消", + "save_failed": "未成功。请重试。", + "timeout_label": "锁定时间", + "timeout_desc": "任一窗口或标签页未使用的时间。", + "lock_now": "立即锁定", + "lock_now_desc": "立即锁定所有窗口和标签页。", + "disable_title": "关闭应用锁?", + "disable_body": "输入 PIN 以将其关闭。", + "disable_confirm": "关闭", + "passkey_label": "通行密钥", + "passkey_desc": "使用能够保护密钥的通行密钥或安全密钥解锁。并非全部都支持,PIN 仍然可用。", + "passkey_add": "添加通行密钥…", + "passkey_add_title": "添加通行密钥", + "passkey_remove": "移除", + "passkey_added": "已添加通行密钥。", + "passkey_no_prf": "此通行密钥无法保护密钥。PIN 仍然可用。", + "passkey_failed": "通行密钥不起作用。请输入 PIN。", + "use_passkey": "使用通行密钥", + "use_hello": "使用 Windows Hello", + "use_touchid": "使用 Touch ID", + "os_add": "打开…", + "os_add_title": "使用 {method}", + "os_remove": "关闭", + "os_desc": "{method} 是应用进行的检查。确认后,应用从此计算机的加密存储中读取密钥。在您的用户帐户下运行的程序无需 {method} 也能读取,因此 PIN 的保护更强。PIN 仍然可用。", + "os_failed": "未成功。请输入 PIN。", + "lock_title": "{app} 已锁定", + "locking": "正在锁定…", + "pin_prompt": "输入 PIN 以继续。", + "unlock": "解锁", + "unlocking": "正在解锁…", + "wrong_pin": "此 PIN 不正确。", + "unlock_failed": "解锁未成功。请重试。", + "blocked_later": "尝试次数过多。请稍后再试。", + "forgot": "忘记了 PIN?", + "forgot_title": "删除本地数据并退出登录?", + "forgot_body": "忘记的 PIN 无法恢复。这会删除此设备上保存的邮件并退出所有帐户,以便您重新登录。服务器上的邮件会保留。此设备上仍在等待发送的邮件将丢失。", + "forgot_confirm": "删除并退出登录", + "unlock_in_main": "请在主窗口中解锁。", + "open_main": "转到主窗口", + "setup_required_title": "您的组织要求使用应用锁", + "setup_required_body": "请选择一个 PIN。锁定后打开 {app} 时需要它。", + "draft_saved_notice": "您正在撰写的内容已在锁定前保存到草稿。", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# 分钟}}", + "blocked_seconds": "尝试次数过多。请在 {count, plural, other {# 秒}}后重试。", + "blocked_minutes": "尝试次数过多。请在 {count, plural, other {# 分钟}}后重试。" + }, "desktop_sign_in": { "browser_button": "使用浏览器登录", "browser_waiting": "请在浏览器中完成登录", diff --git a/public/sw.js b/public/sw.js index 22e4174ba..8ee09656c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -506,6 +506,10 @@ self.addEventListener("message", (event) => { event.waitUntil(recordClientLogins(event.source && event.source.id, data.logins)); return; } + if (data.type === "app-lock") { + event.waitUntil(writeAppLockState(data.state)); + return; + } if (data.type === "offline-shell") { // The switch is kept before the event ends. The shell's files are // fetched without holding the event open: a worker busy with an event @@ -614,7 +618,9 @@ async function handlePush(event) { // a generic "New mail" so the user still sees something. let preview = null; let previewOk = false; - try { + // Locked: not a word about the message, so it isn't even looked up. + const lockHidesContent = await appLockHidesContent(); + if (!lockHidesContent) try { const query = new URLSearchParams(); if (accountId) query.set("accountId", accountId); if (freshIds.length > 0) query.set("emailId", freshIds[freshIds.length - 1]); @@ -777,6 +783,49 @@ async function showWithoutAlert(groupTag) { // every so often. const QUIET_WINDOW_MS = 30_000; +// ---- App lock: while locked a notification says nothing about the message ---- + +function appLockStateKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/app-lock`; +} + +async function writeAppLockState(state) { + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put(appLockStateKey(), new Response(JSON.stringify({ state }), { headers: { "Content-Type": "application/json" } })); + if (state === "locked") await closeMailNotifications(); +} + +/** What's on screen from before the lock names senders and subjects. */ +async function closeMailNotifications() { + try { + const shown = await self.registration.getNotifications(); + for (const notification of shown) { + if (typeof notification.tag === "string" && notification.tag.startsWith("bulwark-mail:")) notification.close(); + } + } catch (_) { + // Nothing more a worker can do. + } +} + +/** + * The page says "locked" when it locks. It can't say so when the browser just closes, so + * "unlocked" only counts with a window open: the key is in a window's memory or nowhere. + */ +async function appLockHidesContent() { + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(appLockStateKey()); + const stored = res ? await res.json() : null; + if (!stored || stored.state === "off") return false; + if (stored.state !== "unlocked") return true; + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + return windows.length === 0; + } catch (_) { + // Can't tell: a generic notification loses little. + return true; + } +} + function lastAlertKey() { return `${self.location.origin}${BASE_PATH}/__push-state/last-alert`; } @@ -1362,6 +1411,8 @@ async function postNotificationAction(action, data) { /** True when the action is done and nothing has to open. */ async function runNotificationAction(action, data, reply, allClients) { + // An old notification's button doesn't act on mail while the app is locked. + if (await appLockHidesContent()) return false; const text = action === "reply" && typeof reply === "string" ? reply.trim() : ""; const { result, client } = await askOpenClients({ kind: "notification-action", diff --git a/stores/__tests__/auth-store-keep-local.test.ts b/stores/__tests__/auth-store-keep-local.test.ts index 99ba77f90..4779e56bd 100644 --- a/stores/__tests__/auth-store-keep-local.test.ts +++ b/stores/__tests__/auth-store-keep-local.test.ts @@ -222,4 +222,17 @@ describe('a login without "remember me"', () => { await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); expect(lost.consumeLost()).toEqual(['Unsent']); }); + + it('keeps its data when the reload was the app lock\'s: it has to sign in again, and loses nothing', async () => { + localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: false })); + useAccountStore.setState({ accounts: [{ ...entry(ALICE, 0), rememberMe: false }] } as never); + tokenAnswers(() => answer(200)); + await useAuthStore.getState().checkAuth(); + expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined(); + expect(state.isEvicted(ALICE)).toBe(true); + offline.startOfflineMode(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(purge.files).toBe(0); + expect(lost.consumeLost()).toEqual([]); + }); }); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9fc0c7ca3..e8616d04d 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -22,6 +22,7 @@ import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; +import { isAppLockedNow, isAppLockOn } from '@/lib/app-lock/state'; import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut, type SignOutCleanupOptions } from '@/lib/sign-out-cleanup'; import { forgetPrincipals } from '@/lib/delegation/known-principals'; import { useSearchHistoryStore } from './search-history-store'; @@ -2967,7 +2968,9 @@ export const useAuthStore = create()( forgottenAccounts += 1; evictAccount(account.id); // The user's choice on what may be a shared computer: its local data goes too. - accountStore.removeAccount(account.id, 'explicit'); + // Not when the app lock is on: its reload ended this page, not the session, so + // the mail and the outbox wait for the next sign-in. + accountStore.removeAccount(account.id, isAppLockOn() ? 'evicted' : 'explicit'); if (account.authMode === 'oauth') void clearSlotCredentials(account.cookieSlot, true); return; } @@ -3484,6 +3487,8 @@ export const useAuthStore = create()( }, startSharedSession: async (options) => { + // Locked: not from another window, and not from the cookies either. + if (isAppLockedNow()) return 'no-session'; set({ isLoading: true, client: null }); const accountStore = useAccountStore.getState(); const answer = await requestWindowSession(options?.accountId ? { accountId: options.accountId } : {}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 8bff6f55d..8354976a4 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -38,6 +38,7 @@ import { SendCancelledCleanupError } from "@/lib/send-failure-message"; import type { StorageQuota } from "@/lib/quota"; import { isArchiveActionEnabled } from "@/lib/archive-gate"; import { getOutboxHooks } from "@/lib/offline/outbox-hooks"; +import { isAppLockOn } from "@/lib/app-lock/state"; import { useOfflineStore } from "@/lib/offline/state"; import { READ_FILTER, UNREAD_FILTER, UNREAD_SECTION_SIZE, andFilters, isUnreadFirstView, mergeUnreadSection, @@ -8734,6 +8735,8 @@ if (typeof window !== 'undefined') { if (typeof account !== 'string' || !account) return; // Its mail lives encrypted in the store, and not in plain localStorage too. if (useOfflineStore.getState().enabled.includes(account)) return; + // Nor with an app lock, which must not leave subjects readable on disk. + if (isAppLockOn()) return; try { window.localStorage.setItem(EMAIL_SNAPSHOT_KEY, JSON.stringify({ v: EMAIL_SNAPSHOT_VERSION, From 099e3fa8875a90b08dd43eb43d30edc3b55f0755 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:09:37 +0100 Subject: [PATCH 18/45] test: check calendar reminders in the browser on both servers: offline, one tab, system notification, missed summary --- integration/tests/24-do-reminders.spec.ts | 226 ++++++++++++++++++++ lib/store/__tests__/calendar-no-dom.test.ts | 2 +- 2 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 integration/tests/24-do-reminders.spec.ts diff --git a/integration/tests/24-do-reminders.spec.ts b/integration/tests/24-do-reminders.spec.ts new file mode 100644 index 000000000..19699dcce --- /dev/null +++ b/integration/tests/24-do-reminders.spec.ts @@ -0,0 +1,226 @@ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, jmapAs, requireEntEnv, shot, userPage } from './helpers/ent'; + +/** + * Calendar reminders from the local store, in a real browser on a production build and + * a VM 106 test Stalwart, with real time passing: the store's worker has its own clock. + * + * The account is ent-dave, whom the other specs leave alone. Snooze and the native + * buttons belong to the desktop app: repos/desktop test/reminders-real.spec.ts. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(900_000); +// The headless shell denies notifications; full Chromium really shows them. +test.use({ channel: 'chromium' }); + +const RUN = Date.now().toString(36); +const USER = ENT.dave; +const ZONE = 'Europe/Berlin'; +const CORE = 'urn:ietf:params:jmap:core'; +const CALENDARS = 'urn:ietf:params:jmap:calendars'; +/** ledger.ts AWAY_MS and a margin. */ +const AWAY_MS = 6.5 * 60_000; +/** How long a change on the server may take to reach the store. */ +const SYNC_MS = 30_000; + +type J = Awaited>; +let client: J; +let context: BrowserContext; +let page: Page; +const created: string[] = []; + +async function call(calls: [string, Record, string][]) { + const response = await client.request(calls, [CORE, CALENDARS]); + for (const [name, result] of response.methodResponses) { + if (name === 'error') throw new Error(`JMAP error: ${JSON.stringify(result)}`); + } + return response.methodResponses as [string, Record, string][]; +} + +/** A wall clock in the event's zone. */ +function wall(ms: number): string { + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', + }).format(new Date(ms)); + return parts.replace(' ', 'T'); +} + +const OFFSET_MIN = 5; + +/** An event whose alert fires at `fireAt`. */ +async function eventFiringAt(title: string, fireAt: number, extra: Record = {}): Promise { + const [[, calendars]] = await call([['Calendar/get', { accountId: client.accountId, properties: ['isDefault'] }, '0']]); + const list = calendars.list as Array<{ id: string; isDefault: boolean }>; + const calendar = list.find((entry) => entry.isDefault) ?? list[0]; + const [[, result]] = await call([['CalendarEvent/set', { + accountId: client.accountId, + create: { + e: { + '@type': 'Event', + calendarIds: { [calendar.id]: true }, + timeZone: ZONE, + title, + start: wall(fireAt + OFFSET_MIN * 60_000), + duration: 'PT30M', + useDefaultAlerts: false, + alerts: { a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: `-PT${OFFSET_MIN}M`, relativeTo: 'start' } } }, + ...extra, + }, + }, + sendSchedulingMessages: false, + }, '0']]); + const id = (result.created as Record | undefined)?.e?.id; + if (!id) throw new Error(`Event not created: ${JSON.stringify(result)}`); + created.push(id); + return id; +} + +/** Settings > Backup & reset > Offline, switched on, and the first sync done. */ +async function enableOffline(target: Page): Promise { + await target.goto('/settings/backup/offline'); + const toggle = target.getByTestId('offline-switch'); + await toggle.waitFor({ state: 'visible', timeout: 60_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(target.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); +} + +const toast = (target: Page, text: string) => target.locator('.toast-item').filter({ hasText: text }); + +test.beforeAll(async ({ browser }) => { + requireEntEnv(); + client = await jmapAs(USER); + page = await userPage(browser, USER); + context = page.context(); + await enableOffline(page); + await page.goto('/'); +}); + +test.afterAll(async () => { + if (created.length > 0) { + await call([['CalendarEvent/set', { accountId: client.accountId, destroy: created, sendSchedulingMessages: false }, '0']]).catch(() => {}); + } + await context?.close(); +}); + +test('an occurrence of a series fires from the store with the network gone', async () => { + const title = `rem-series-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 30_000; + // The series began weeks ago, so only an expansion finds today's occurrence. + const [day, time] = wall(fireAt + OFFSET_MIN * 60_000).split('T'); + const weeksAgo = new Date(Date.parse(`${day}T00:00:00Z`) - 28 * 24 * 3_600_000).toISOString().slice(0, 10); + await eventFiringAt(title, fireAt, { + start: `${weeksAgo}T${time}`, + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + }); + await page.waitForTimeout(SYNC_MS); + await context.setOffline(true); + try { + expect(await page.evaluate(() => navigator.onLine)).toBe(false); + await page.waitForTimeout(Math.max(fireAt - Date.now() - 3000, 0)); + await expect(toast(page, title)).toHaveCount(0); + await expect(toast(page, title)).toBeVisible({ timeout: 30_000 }); + await shot(page, 'desktop-reminders-offline-toast'); + } finally { + await context.setOffline(false); + } + await toast(page, title).getByRole('button').first().click().catch(() => {}); +}); + +test('fires in one tab of two', async () => { + const second = await context.newPage(); + await second.goto('/'); + const title = `rem-tabs-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 20_000; + await eventFiringAt(title, fireAt); + // A reminder's toast leaves after a while, so note the most each tab ever showed. + const most = [0, 0]; + const deadline = fireAt + 40_000; + let lastSeen = 0; + while (Date.now() < deadline && (lastSeen === 0 || Date.now() < lastSeen + 8000)) { + const counts = await Promise.all([page, second].map((target) => toast(target, title).count())); + counts.forEach((count, index) => { + most[index] = Math.max(most[index], count); + }); + if (lastSeen === 0 && counts.some((count) => count > 0)) lastSeen = Date.now(); + await second.waitForTimeout(250); + } + expect(most[0] + most[1]).toBe(1); + await second.close(); + await page.bringToFront(); +}); + +test('adds a system notification when permission is granted and the tab is not in front', async () => { + await context.grantPermissions(['notifications']); + await page.reload(); + await page.evaluate(() => { + document.hasFocus = () => false; + }); + const title = `rem-system-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 15_000; + await eventFiringAt(title, fireAt); + await expect(toast(page, title)).toBeVisible({ timeout: fireAt - Date.now() + 30_000 }); + const shown = await page.evaluate(async (wanted) => { + const registration = await navigator.serviceWorker.ready; + return (await registration.getNotifications()).filter((item) => item.title === wanted).map((item) => ({ tag: item.tag, data: item.data as unknown })); + }, title); + expect(shown).toHaveLength(1); + expect(shown[0].tag).toContain('calendar-reminder:'); + expect(shown[0].data).toEqual({ kind: 'calendar-reminder' }); + await context.clearPermissions(); +}); + +test('what came due with the app closed is summarised once, and not as a burst', async () => { + const one = `rem-missed-a-${RUN}`; + const two = `rem-missed-b-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 20_000; + await eventFiringAt(one, fireAt); + await eventFiringAt(two, fireAt + 20_000, { virtualLocations: { v: { '@type': 'VirtualLocation', name: 'Meet', uri: 'https://meet.jit.si/rem-test' } } }); + await page.waitForTimeout(SYNC_MS); + + // Closed like an app: nothing of the page is left, and the store's data stays. + const closedAt = Date.now(); + await page.close(); + await new Promise((resolve) => setTimeout(resolve, Math.max(fireAt + 20_000 + AWAY_MS - closedAt, AWAY_MS))); + + page = await context.newPage(); + await page.goto('/'); + const summary = page.locator('.toast-item').filter({ hasText: one }); + await expect(summary).toBeVisible({ timeout: 60_000 }); + await expect(summary).toContainText(two); + await expect(toast(page, one)).toHaveCount(1); + await shot(page, 'desktop-reminders-missed-toast'); + + await summary.getByRole('button', { name: 'Show' }).click(); + const list = page.getByTestId('missed-reminders'); + await expect(list).toContainText(one); + await expect(list).toContainText(two); + await expect(list.getByRole('button', { name: 'Join' })).toHaveCount(1); + await shot(page, 'desktop-reminders-missed'); + await page.getByRole('button', { name: 'Done' }).click(); + await expect(list).toHaveCount(0); + + // Once: not after a reload, nor from a new tab. + await page.reload(); + await page.waitForTimeout(30_000); + await expect(page.locator('.toast-item').filter({ hasText: 'rem-missed' })).toHaveCount(0); + await expect(page.getByTestId('missed-reminders')).toHaveCount(0); +}); + +test('with the store off, the app reminds as it always did', async () => { + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await toggle.waitFor({ state: 'visible', timeout: 60_000 }); + await toggle.click(); + const confirm = page.getByRole('button', { name: /turn off|switch off|delete/i }).last(); + if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 }); + await page.goto('/'); + const title = `rem-plain-${RUN}`; + const fireAt = Date.now() + 20_000; + await eventFiringAt(title, fireAt); + // The in-app path reads the server every five minutes, or on a push. + await expect(toast(page, title)).toBeVisible({ timeout: 120_000 }); + await page.waitForTimeout(5000); + await expect(toast(page, title)).toHaveCount(1); +}); diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts index 8cd3d10a1..1fcd8543f 100644 --- a/lib/store/__tests__/calendar-no-dom.test.ts +++ b/lib/store/__tests__/calendar-no-dom.test.ts @@ -22,7 +22,7 @@ async function inputsOf(entry: string): Promise { } describe('what the worker pulls in', () => { - for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts']) { + for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts', 'lib/store/reminders/index.ts']) { it(`${entry} reaches no UI code`, async () => { const inputs = await inputsOf(entry); expect(inputs.length).toBeGreaterThan(3); From 8523289a4babfe0536edc1e1e340dd16a28bf2d6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:19:44 +0100 Subject: [PATCH 19/45] feat: let an admin wipe a lost device, and clear an account the server refuses for good Until now a lost laptop kept its offline mail for as long as anyone could open it, and an account whose login the server had ended kept its copy too. - `lib/devices`, `app/api/devices`: browsers with offline on and desktop apps register, in a durable collection with no expiry. A check-in goes by a per-device secret, so a revoked device still hears its wipe. - `app/api/admin/devices` and the org devices page: wipe one device or all of a user's, with audit entries for each request and each confirmation. - `lib/offline/device-wipe.ts`: a start finishes any wipe cut short, then waits up to a second for the instance before showing the local copy. A wipe takes the store, keys, plugin databases and handoffs, signs out, and reports back, again at the next contact if the report failed. - `lib/jmap/client.ts`, `stores/auth-store.ts`: only `invalid_grant`, the session limit, or a 401 lasting past a re-auth count as refused. A 500, a proxy page or an outage never do. - Settings: "Remove everything from this device", with a confirmation. `lib/shared-store/__tests__/import-data.test.ts` changes its assertion because it lists every collection the import copies, and `devices` is new. --- app/(main)/[locale]/layout.tsx | 2 + app/(main)/[locale]/login/page.tsx | 10 + .../__tests__/device-registry-panel.test.tsx | 93 +++ .../admin/_pages/device-registry-panel.tsx | 250 ++++++++ app/(main)/admin/_pages/logs.tsx | 4 + app/(main)/admin/_pages/org-devices.tsx | 2 + app/api/admin/devices/route.ts | 114 ++++ app/api/devices/__tests__/route.test.ts | 203 ++++++ app/api/devices/route.ts | 71 +++ app/api/devices/wiped/route.ts | 63 ++ app/api/plugin-secrets/route.ts | 14 + .../__tests__/wipe-device-section.test.tsx | 49 ++ components/offline/device-wipe-host.tsx | 31 + components/offline/offline-settings.tsx | 2 + components/offline/wipe-device-section.tsx | 70 +++ integration/tests/24-do-remote-wipe.spec.ts | 364 +++++++++++ lib/admin/admin-nav.ts | 1 + lib/devices/__tests__/client.test.ts | 94 +++ lib/devices/__tests__/registry.test.ts | 177 ++++++ lib/devices/client.ts | 90 +++ lib/devices/registry.ts | 231 +++++++ lib/devices/types.ts | 30 + lib/devices/wipe-state.ts | 135 ++++ .../__tests__/credentials-refused.test.ts | 167 +++++ lib/jmap/client-interface.ts | 6 + lib/jmap/client.ts | 44 +- lib/offline/__tests__/device-wipe.test.ts | 593 ++++++++++++++++++ lib/offline/credentials-rejected.ts | 6 +- lib/offline/device-wipe.ts | 491 +++++++++++++++ lib/offline/wipe-registry.ts | 39 ++ lib/plugin-secrets/vault.ts | 5 + .../__tests__/import-data.test.ts | 9 +- lib/shared-store/export.ts | 2 + lib/shared-store/import/data.ts | 2 + lib/store/client/keys.ts | 49 ++ locales/ar/common.json | 17 + locales/ca/common.json | 17 + locales/cs/common.json | 17 + locales/da/common.json | 17 + locales/de/common.json | 17 + locales/en/common.json | 17 + locales/es/common.json | 17 + locales/et/common.json | 17 + locales/fa/common.json | 17 + locales/fr/common.json | 17 + locales/he/common.json | 17 + locales/hu/common.json | 17 + locales/it/common.json | 17 + locales/ja/common.json | 17 + locales/ko/common.json | 17 + locales/lv/common.json | 17 + locales/mn/common.json | 17 + locales/nb/common.json | 17 + locales/nl/common.json | 17 + locales/pl/common.json | 17 + locales/pt/common.json | 17 + locales/ro/common.json | 17 + locales/ru/common.json | 17 + locales/sk/common.json | 17 + locales/th/common.json | 17 + locales/tr/common.json | 17 + locales/uk/common.json | 17 + locales/vi/common.json | 17 + locales/zh-TW/common.json | 17 + locales/zh/common.json | 17 + scripts/lite/lib.mjs | 2 + .../__tests__/auth-store-device-wipe.test.ts | 271 ++++++++ stores/auth-store.ts | 66 +- 68 files changed, 4352 insertions(+), 10 deletions(-) create mode 100644 app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx create mode 100644 app/(main)/admin/_pages/device-registry-panel.tsx create mode 100644 app/api/admin/devices/route.ts create mode 100644 app/api/devices/__tests__/route.test.ts create mode 100644 app/api/devices/route.ts create mode 100644 app/api/devices/wiped/route.ts create mode 100644 components/offline/__tests__/wipe-device-section.test.tsx create mode 100644 components/offline/device-wipe-host.tsx create mode 100644 components/offline/wipe-device-section.tsx create mode 100644 integration/tests/24-do-remote-wipe.spec.ts create mode 100644 lib/devices/__tests__/client.test.ts create mode 100644 lib/devices/__tests__/registry.test.ts create mode 100644 lib/devices/client.ts create mode 100644 lib/devices/registry.ts create mode 100644 lib/devices/types.ts create mode 100644 lib/devices/wipe-state.ts create mode 100644 lib/jmap/__tests__/credentials-refused.test.ts create mode 100644 lib/offline/__tests__/device-wipe.test.ts create mode 100644 lib/offline/device-wipe.ts create mode 100644 lib/offline/wipe-registry.ts create mode 100644 stores/__tests__/auth-store-device-wipe.test.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 992bef2ec..b552b240d 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -27,6 +27,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; 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 { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; @@ -115,6 +116,7 @@ export default async function LocaleLayout({ {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } + {!IS_LITE && } {!IS_LITE && } diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index bf9b69c44..e5ea02def 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -12,6 +12,7 @@ import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { useAuthStore, consumeSignedOut, SESSION_EXPIRED_DRAFTS_UNSAVED_KEY } from "@/stores/auth-store"; import { consumeLost } from "@/lib/offline/lost"; +import { consumeWipeNote } from "@/lib/devices/wipe-state"; import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -194,6 +195,7 @@ function LoginPageContent() { const [desktopWaiting, setDesktopWaiting] = useState(false); const tSessionLimits = useTranslations("session_limits"); const tOffline = useTranslations("offline"); + const tOfflineWipe = useTranslations("offlineWipe"); const tTheme = useTranslations("settings.appearance.theme"); const params = useParams(); const searchParams = useSearchParams(); @@ -282,8 +284,11 @@ function LoginPageContent() { const [sessionEnd, setSessionEnd] = useState(null); // Offline outbox messages a sign-out deleted unsent (#523), named once. const [outboxLost, setOutboxLost] = useState([]); + // Unsent mail a wipe took with it: how many, never which. + const [wipeLost, setWipeLost] = useState(null); useEffect(() => { setOutboxLost(consumeLost()); + setWipeLost(consumeWipeNote()?.unsent ?? null); }, []); const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false); const [showPassword, setShowPassword] = useState(false); @@ -1455,6 +1460,11 @@ function LoginPageContent() { {/* Form section */}
+ {wipeLost !== null && ( +
+ {wipeLost > 0 ? tOfflineWipe("note_unsent", { count: wipeLost }) : tOfflineWipe("note")} +
+ )} {outboxLost.length > 0 && (
{tOffline("signout.lost", { subjects: outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })} diff --git a/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx b/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx new file mode 100644 index 000000000..535f76f13 --- /dev/null +++ b/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DeviceView } from '@/lib/devices/types'; +import { DeviceRegistryPanel } from '../device-registry-panel'; + +const device = (over: Partial): DeviceView => ({ + id: `${'a'.repeat(64)}/${'b'.repeat(32)}`, + userKey: 'a'.repeat(64), + user: 'alice@example.org', + server: 'https://mail.example.org', + platform: 'Chrome, Windows', + appVersion: '1.2.3', + firstSeen: '2026-11-01T10:00:00.000Z', + lastSeen: '2026-11-03T10:00:00.000Z', + wipe: null, + wipedAt: null, + wipeReason: null, + ...over, +}); + +let rows: DeviceView[]; +let posts: Array>; + +beforeEach(() => { + posts = []; + rows = [device({})]; + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => { + if (init?.method === 'POST') { + posts.push(JSON.parse(String(init.body))); + return new Response('{}', { status: 200 }); + } + expect(String(input)).toContain('/api/admin/devices'); + return new Response(JSON.stringify({ devices: rows, total: rows.length }), { status: 200 }); + })); +}); + +describe('the registered devices', () => { + it('lists user, platform, version and last seen', async () => { + render(); + const row = await screen.findByTestId('device-row'); + expect(screen.getByText('alice@example.org')).toBeTruthy(); + expect(row.textContent).toContain('Chrome, Windows'); + expect(row.textContent).toContain('Version 1.2.3'); + expect(row.textContent).toContain('Last seen'); + }); + + it('wipes only after the confirmation, naming who and what', async () => { + render(); + fireEvent.click(await screen.findByTestId('device-wipe')); + expect(posts).toEqual([]); + const dialog = await screen.findByRole('alertdialog').catch(() => screen.getByRole('dialog')); + expect(dialog.textContent).toContain('alice@example.org'); + expect(dialog.textContent).toContain('Mail that was waiting to be sent is lost'); + expect(dialog.textContent).toContain('S/MIME and OpenPGP keys imported in that browser or app go for every account there'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Wipe' })); + await waitFor(() => expect(posts).toEqual([{ action: 'wipe', id: rows[0].id }])); + }); + + it('offers all of a user\'s devices once there are several', async () => { + rows = [device({}), device({ id: `${'a'.repeat(64)}/${'c'.repeat(32)}`, platform: 'Desktop app, Windows' })]; + render(); + fireEvent.click(await screen.findByTestId('devices-wipe-user')); + const dialog = await screen.findByRole('alertdialog').catch(() => screen.getByRole('dialog')); + fireEvent.click(within(dialog).getByRole('button', { name: 'Wipe' })); + await waitFor(() => expect(posts).toEqual([{ action: 'wipe-user', userKey: 'a'.repeat(64) }])); + }); + + it('says a wipe is waiting and lets the admin take it back', async () => { + rows = [device({ wipe: { requestedAt: '2026-11-03T11:00:00.000Z', by: 'admin' } })]; + render(); + const row = await screen.findByTestId('device-row'); + expect(row.textContent).toContain('Wipe waiting'); + expect(screen.queryByTestId('device-wipe')).toBeNull(); + fireEvent.click(screen.getByTestId('device-cancel')); + await waitFor(() => expect(posts).toEqual([{ action: 'cancel', id: rows[0].id }])); + }); + + it('shows a wiped device as such, with nothing more to wipe', async () => { + rows = [device({ wipedAt: '2026-11-03T12:00:00.000Z', wipeReason: 'user' })]; + render(); + const row = await screen.findByTestId('device-row'); + expect(row.textContent).toContain('Wiped'); + expect(row.textContent).toContain('by the user'); + expect(screen.queryByTestId('device-wipe')).toBeNull(); + expect(screen.getByTestId('device-forget')).toBeTruthy(); + }); + + it('says so when nothing registered', async () => { + rows = []; + render(); + expect(await screen.findByTestId('devices-empty')).toBeTruthy(); + }); +}); diff --git a/app/(main)/admin/_pages/device-registry-panel.tsx b/app/(main)/admin/_pages/device-registry-panel.tsx new file mode 100644 index 000000000..db4ef63fa --- /dev/null +++ b/app/(main)/admin/_pages/device-registry-panel.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Trash2, X } from '@/components/icons'; +import { AdminList, AdminListRow } from '@/components/admin/list'; +import { Badge } from '@/components/admin/badge'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { DeviceView } from '@/lib/devices/types'; + +const PAGE_SIZE = 100; + +type Pending = + | { kind: 'device'; device: DeviceView } + | { kind: 'user'; userKey: string; user: string; count: number } + | { kind: 'forget'; device: DeviceView }; + +function when(iso: string | null): string { + return iso ? new Date(iso).toLocaleString() : ''; +} + +/** + * Browsers with offline mode on and desktop apps. A wipe waits for the device's + * next contact, and the row says so until the device confirms. Its own section, + * with no draft and no save bar: each action happens when it's confirmed. + */ +export function DeviceRegistryPanel() { + const [devices, setDevices] = useState(null); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [query, setQuery] = useState(''); + const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [pending, setPending] = useState(null); + + const load = useCallback(async () => { + try { + const params = new URLSearchParams({ offset: String(offset), limit: String(PAGE_SIZE) }); + if (search) params.set('q', search); + const res = await apiFetch(`/api/admin/devices?${params}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json() as { devices: DeviceView[]; total: number }; + setDevices(data.devices); + setTotal(data.total); + setError(null); + } catch (e) { + setError(`Could not load the devices (${(e as Error).message})`); + } + }, [offset, search]); + + useEffect(() => { void load(); }, [load]); + + const act = async (key: string, body: Record) => { + setBusy(key); + try { + const res = await apiFetch('/api/admin/devices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await load(); + } catch (e) { + setError(`That didn't work (${(e as Error).message})`); + } finally { + setBusy(null); + } + }; + + const groups = useMemo(() => { + const byUser = new Map(); + for (const device of devices ?? []) { + const rows = byUser.get(device.userKey) ?? []; + rows.push(device); + byUser.set(device.userKey, rows); + } + return [...byUser.entries()].map(([userKey, rows]) => ({ + userKey, + user: rows[0].user, + server: rows[0].server, + rows: [...rows].sort((a, b) => b.lastSeen.localeCompare(a.lastSeen)), + })); + }, [devices]); + + const confirmPending = () => { + if (!pending) return; + setPending(null); + if (pending.kind === 'device') void act(pending.device.id, { action: 'wipe', id: pending.device.id }); + else if (pending.kind === 'forget') void act(pending.device.id, { action: 'forget', id: pending.device.id }); + else void act(pending.userKey, { action: 'wipe-user', userKey: pending.userKey }); + }; + + return ( + +
{ event.preventDefault(); setOffset(0); setSearch(query.trim()); }} + > + setQuery(event.target.value)} + placeholder="Search by user" + aria-label="Search by user" + className="h-8 min-w-48 flex-1 rounded-md border border-input bg-background px-2 text-sm" + data-testid="devices-search" + /> + +
+ {error &&

{error}

} + {devices !== null && devices.length === 0 && ( +

+ {search ? 'No device matches that search.' : 'No device has registered yet.'} +

+ )} +
+ {groups.map((group) => { + const wipeable = group.rows.filter((device) => !device.wipe && !device.wipedAt); + return ( +
+
+

+ {group.user} + {group.server} +

+ {wipeable.length > 1 && ( + + )} +
+ + {group.rows.map((device) => ( + Wipe waiting + : device.wipedAt + ? Wiped + : null} + description={device.wipe + ? `Asked ${when(device.wipe.requestedAt)}. Happens the next time this device connects.` + : device.wipedAt + ? `Wiped ${when(device.wipedAt)}${device.wipeReason && device.wipeReason !== 'admin' ? ` (${wipeReasonLabel(device.wipeReason)})` : ''}` + : undefined} + meta={`${device.appVersion ? `Version ${device.appVersion} · ` : ''}Last seen ${when(device.lastSeen)} · First seen ${when(device.firstSeen)}`} + control={( +
+ {!device.wipe && !device.wipedAt && ( + + )} + {device.wipe && ( + + )} + +
+ )} + /> + ))} +
+
+ ); + })} +
+ {total > PAGE_SIZE && ( +
+ {offset + 1} to {Math.min(offset + PAGE_SIZE, total)} of {total} + + + + +
+ )} + setPending(null)} + onConfirm={confirmPending} + title={pending?.kind === 'forget' ? 'Forget this device?' : 'Wipe this data?'} + message={confirmMessage(pending)} + confirmText={pending?.kind === 'forget' ? 'Forget' : 'Wipe'} + variant="destructive" + /> +
+ ); +} + +function wipeReasonLabel(reason: NonNullable): string { + switch (reason) { + case 'user': return 'by the user'; + case 'rejected': return 'the server refused the login'; + default: return reason; + } +} + +function confirmMessage(pending: Pending | null): string { + if (!pending) return ''; + const consequences = 'Mail, keys and files kept on it are deleted and the account is signed out the next time it connects. Mail that was waiting to be sent is lost. S/MIME and OpenPGP keys imported in that browser or app go for every account there, because they are not kept per account: anyone using them has to import them again. Keys in the OS certificate store stay. The mail on the server is not touched.'; + if (pending.kind === 'device') return `${pending.device.platform || 'This device'} of ${pending.device.user}. ${consequences}`; + if (pending.kind === 'user') return `All ${pending.count} devices of ${pending.user}. ${consequences}`; + return pending.device.wipe + ? `The wipe waiting for ${pending.device.platform || 'this device'} of ${pending.device.user} is dropped with its row. The device will not be told.` + : `${pending.device.platform || 'This device'} of ${pending.device.user} leaves this list. It comes back as a new row if it registers again.`; +} diff --git a/app/(main)/admin/_pages/logs.tsx b/app/(main)/admin/_pages/logs.tsx index 4077e9732..d6930eeb2 100644 --- a/app/(main)/admin/_pages/logs.tsx +++ b/app/(main)/admin/_pages/logs.tsx @@ -63,6 +63,10 @@ export function LogsPage() { + + + + + setAsking(false)} + onConfirm={confirmed} + title={t('confirm_title')} + message={t('confirm_message')} + details={( +
    +
  • {t('confirm_mail')}
  • + {unsent > 0 &&
  • {t('confirm_unsent', { count: unsent })}
  • } +
  • {t('confirm_keys')}
  • +
  • {t('confirm_plugins')}
  • +
  • {t('confirm_files')}
  • +
  • {t('confirm_accounts')}
  • +
+ )} + confirmText={t('confirm_button')} + variant="destructive" + /> +
+ ); +} diff --git a/integration/tests/24-do-remote-wipe.spec.ts b/integration/tests/24-do-remote-wipe.spec.ts new file mode 100644 index 000000000..d88ac072a --- /dev/null +++ b/integration/tests/24-do-remote-wipe.spec.ts @@ -0,0 +1,364 @@ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, ENT_PASSWORD, adminContext, deliver, requireEntEnv, shot, userPage } from './helpers/ent'; +import { JMAP_URL } from './helpers/config'; + +/** + * Remote wipe, through the real UI on a production build, on both VM 106 + * Stalwarts, with the ent-* accounts: + * - a browser with offline on registers; the admin wipes it from + * Organisation > Devices; at its next connect the raw OPFS files, the key + * database, plugin databases and the handoff cache are gone, sign-in is + * required, the login page gives the count of lost mail, the instance has the + * confirmation and the audit log has both entries; + * - at a start the wipe comes before anything from the device is shown; + * - with the instance out of reach mail stays and the wipe keeps waiting, and it + * happens once the device is back; + * - "Remove everything from this device" in settings. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(300_000); + +const RUN = Date.now().toString(36); + +interface Device { + id: string; + user: string; + platform: string; + firstSeen: string; + wipe: { requestedAt: string } | null; + wipedAt: string | null; + wipeReason: string | null; +} + +async function devicesOf(admin: BrowserContext, email: string): Promise { + const res = await admin.request.get(`/api/admin/devices?q=${encodeURIComponent(email)}`); + expect(res.ok(), `listing devices: ${res.status()}`).toBe(true); + return (await res.json() as { devices: Device[] }).devices; +} + +/** Rows earlier runs left. */ +async function forgetDevices(admin: BrowserContext, email: string): Promise { + for (const device of await devicesOf(admin, email)) { + const res = await admin.request.post('/api/admin/devices', { data: { action: 'forget', id: device.id } }); + expect(res.ok()).toBe(true); + } +} + +async function theDevice(admin: BrowserContext, email: string): Promise { + let found: Device | undefined; + await expect.poll(async () => { + found = (await devicesOf(admin, email))[0]; + return found?.id ?? ''; + }, { timeout: 60_000, message: `${email} registers` }).not.toBe(''); + return found!; +} + +async function adminWipe(admin: BrowserContext, id: string): Promise { + const res = await admin.request.post('/api/admin/devices', { data: { action: 'wipe', id } }); + expect(res.ok()).toBe(true); +} + +async function enableOffline(page: Page): Promise { + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + 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 }); +} + +async function toMail(page: Page, subject: string): Promise { + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 }); +} + +async function seedMail(to: string, subject: string): Promise { + await deliver({ from: ENT.bob, to: [to], subject, text: `Body of ${subject}` }); +} + +/** What's in the browser's storage for this origin, read raw, opening nothing that would create it. */ +async function rawStorage(page: Page): Promise<{ databases: string[]; storeFiles: string[]; caches: string[]; keyRecords: number }> { + return page.evaluate(async () => { + const databases = (await indexedDB.databases()).map((database) => database.name ?? ''); + const storeFiles: string[] = []; + const root = await navigator.storage.getDirectory(); + const list = async (dir: FileSystemDirectoryHandle, prefix: string): Promise => { + for await (const handle of (dir as unknown as { values(): AsyncIterable }).values()) { + if (handle.kind === 'directory') await list(handle as FileSystemDirectoryHandle, `${prefix}${handle.name}/`); + else storeFiles.push(`${prefix}${handle.name}`); + } + }; + await list(root, ''); + let keyRecords = 0; + if (databases.includes('bulwark-store')) { + keyRecords = await new Promise((resolve) => { + const open = indexedDB.open('bulwark-store'); + open.onerror = () => resolve(-1); + open.onsuccess = () => { + const db = open.result; + const count = db.transaction('accounts').objectStore('accounts').count(); + count.onsuccess = () => { db.close(); resolve(count.result); }; + count.onerror = () => { db.close(); resolve(-1); }; + }; + }); + } + return { databases, storeFiles, caches: await caches.keys(), keyRecords }; + }); +} + +async function expectStoreGone(page: Page): Promise { + await expect.poll(async () => { + const raw = await rawStorage(page); + return { + keys: raw.keyRecords, + store: raw.databases.includes('bulwark-store'), + legacy: raw.databases.includes('bulwark-offline'), + files: raw.storeFiles, + }; + }, { timeout: 30_000, message: 'nothing of the store is left' }).toEqual({ keys: 0, store: false, legacy: false, files: [] }); +} + +async function expectSignedOut(page: Page): Promise { + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + const cookies = (await page.context().cookies()).map((cookie) => cookie.name); + expect(cookies.filter((name) => /^jmap_(session|stalwart_ctx|rt|at)/.test(name))).toEqual([]); + // And it stays that way. + await page.reload(); + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); +} + +test.beforeAll(() => { + requireEntEnv(); +}); + +test('the admin wipes a device: at its next connect everything on it goes, and the instance hears', async ({ browser }) => { + const subject = `Wipe me ${RUN}`; + const unsent = `Unsent secret ${RUN}`; + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.dave); + await forgetDevices(admin, ENT.alice); + await seedMail(ENT.dave, subject); + + // A second user, so the page shows what an admin really sees. + const other = await userPage(browser, ENT.alice); + await enableOffline(other); + const otherDevice = await theDevice(admin, ENT.alice); + + const page = await userPage(browser, ENT.dave); + await enableOffline(page); + await toMail(page, subject); + const device = await theDevice(admin, ENT.dave); + expect(device.user).toBe(ENT.dave); + expect(device.platform).toContain('Chrom'); + expect(device.wipe).toBeNull(); + + const before = await rawStorage(page); + expect(before.keyRecords).toBe(1); + expect(before.storeFiles.length).toBeGreaterThan(0); + + // What plugins and the launch handoff would have left. + await page.evaluate(async (run) => { + for (const name of ['smime-plugin-store', 'bulwark-openpgp', `plugin-made-${run}`]) { + await new Promise((resolve, reject) => { + const open = indexedDB.open(name, 1); + open.onupgradeneeded = () => { open.result.createObjectStore('keys'); }; + open.onsuccess = () => { open.result.close(); resolve(); }; + open.onerror = () => reject(open.error); + }); + } + const cache = await caches.open('bulwark-launch-handoff-v1'); + await cache.put('/handoff-test', new Response('secret file')); + }, RUN); + + // A message waits in the outbox, so the note has something to count. + await page.context().setOffline(true); + await page.keyboard.press('c'); + const composer = page.locator('[data-testid="email-composer"]'); + await composer.waitFor({ state: 'visible', timeout: 15_000 }); + const to = page.locator('[data-testid="composer-to"] input').first(); + await to.click(); + await to.fill(ENT.bob); + await to.press('Enter'); + await page.locator('[data-testid="composer-subject"]').first().fill(unsent); + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.type('Never sent'); + await page.locator('[data-testid="composer-subject"]').first().focus(); + await page.keyboard.press('Control+Enter'); + await expect(page.getByTestId('offline-banner-outbox')).toHaveText('1 message in the outbox', { timeout: 30_000 }); + + // The admin's side, through the page. + const adminPage = await admin.newPage(); + await adminPage.goto('/admin/org-devices'); + const section = adminPage.locator('[data-user="ent-dave@example.org"]'); + await expect(section.getByTestId('device-row')).toBeVisible({ timeout: 30_000 }); + await expect(adminPage.locator('[data-user="ent-alice@example.org"]').getByTestId('device-row')).toBeVisible(); + await section.scrollIntoViewIfNeeded(); + await shot(adminPage, 'desktop-offline-admin-devices'); + await section.getByTestId('device-wipe').click(); + await expect(adminPage.getByRole('dialog').or(adminPage.getByRole('alertdialog')).first()).toContainText('imported in that browser or app go for every account'); + await adminPage.getByRole('button', { name: 'Wipe', exact: true }).last().click(); + await expect(section.getByTestId('device-row')).toContainText('Wipe waiting', { timeout: 15_000 }); + await shot(adminPage, 'desktop-offline-admin-devices-waiting'); + expect((await devicesOf(admin, ENT.dave))[0].wipe).not.toBeNull(); + + // Back on the network, with the mail server gone: the word comes from the + // instance and needs nothing of the mail server. The outbox can't leave either. + await page.context().route(`${new URL(JMAP_URL).origin}/**`, (route) => route.abort('internetdisconnected')); + await page.context().setOffline(false); + + await expect(page.locator('#username')).toBeVisible({ timeout: 90_000 }); + await expect(page.getByTestId('device-wipe-note')).toContainText('Unsent messages deleted: 1'); + expect(await page.getByTestId('device-wipe-note').textContent()).not.toContain(unsent); + + await expectStoreGone(page); + const raw = await rawStorage(page); + expect(raw.databases).not.toContain('smime-plugin-store'); + expect(raw.databases).not.toContain('bulwark-openpgp'); + expect(raw.databases).not.toContain(`plugin-made-${RUN}`); + expect(raw.caches).not.toContain('bulwark-launch-handoff-v1'); + const stored = await page.evaluate(() => JSON.stringify({ ...localStorage }) + JSON.stringify({ ...sessionStorage })); + expect(stored).not.toContain(unsent); + await page.context().unroute(`${new URL(JMAP_URL).origin}/**`); + + await expectSignedOut(page); + + // The note shows once. + await expect(page.getByTestId('device-wipe-note')).toHaveCount(0); + + const confirmed = (await devicesOf(admin, ENT.dave))[0]; + expect(confirmed.id).toBe(device.id); + expect(confirmed.wipe).toBeNull(); + expect(confirmed.wipedAt).not.toBeNull(); + expect(confirmed.wipeReason).toBe('admin'); + + // The other user's row isn't touched. + expect((await devicesOf(admin, ENT.alice)).find((row) => row.id === otherDevice.id)?.wipe).toBeNull(); + + const audit = async (action: string) => { + const res = await admin.request.get(`/api/admin/audit?action=${action}&limit=50`); + expect(res.ok()).toBe(true); + return (await res.json() as { entries: Array<{ detail: Record }> }).entries.filter((entry) => entry.detail.device === device.id); + }; + expect(await audit('device.wipe.request')).toHaveLength(1); + const confirmations = await audit('device.wipe.confirm'); + expect(confirmations).toHaveLength(1); + expect(confirmations[0].detail).toMatchObject({ user: ENT.dave, reason: 'admin', unsentDeleted: 1 }); + + await adminPage.reload(); + await expect(adminPage.locator('[data-user="ent-dave@example.org"]').getByTestId('device-row')).toContainText('Wiped', { timeout: 30_000 }); + + // A new sign-in is a new device. + await page.fill('#username', ENT.dave); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await expect(page.locator('#username')).toBeHidden({ timeout: 60_000 }); + await enableOffline(page); + await expect.poll(async () => (await devicesOf(admin, ENT.dave)).length, { timeout: 60_000 }).toBe(2); + + await Promise.all([page.context().close(), other.context().close(), admin.close()]); +}); + +test('at a start, the wipe comes before anything from the device is shown', async ({ browser }) => { + const subject = `Start wipe ${RUN}`; + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.alice); + await seedMail(ENT.alice, subject); + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page, subject); + const device = await theDevice(admin, ENT.alice); + const context = page.context(); + + // The mail was on screen here a moment ago, and comes from the device at a start. + await context.addInitScript(() => { + const look = () => { + if (document.querySelector('[data-testid="email-list-item"]')) sessionStorage.setItem('wipe-test-saw-mail', 'yes'); + }; + new MutationObserver(look).observe(document, { childList: true, subtree: true }); + }); + await page.close(); + await adminWipe(admin, device.id); + + const next = await context.newPage(); + await next.goto('/mail'); + await expect(next.locator('#username')).toBeVisible({ timeout: 60_000 }); + expect(await next.evaluate(() => sessionStorage.getItem('wipe-test-saw-mail'))).toBeNull(); + await expect(next.getByTestId('device-wipe-note')).toContainText('removed this account'); + await expectStoreGone(next); + await expect.poll(async () => (await devicesOf(admin, ENT.alice))[0].wipedAt, { timeout: 30_000 }).not.toBeNull(); + + await Promise.all([context.close(), admin.close()]); +}); + +test('with the instance out of reach the mail stays and the wipe waits, until the device is back', async ({ browser }) => { + const subject = `Waits ${RUN}`; + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.carol); + await seedMail(ENT.carol, subject); + const page = await userPage(browser, ENT.carol); + await enableOffline(page); + await toMail(page, subject); + const device = await theDevice(admin, ENT.carol); + await adminWipe(admin, device.id); + + // A start with no network at all: the local copy, as ever. + await page.context().setOffline(true); + await page.reload(); + await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 }); + expect((await rawStorage(page)).keyRecords).toBe(1); + + // The device never got the message, and the row keeps saying so. + const waiting = (await devicesOf(admin, ENT.carol))[0]; + expect(waiting.wipe).not.toBeNull(); + expect(waiting.wipedAt).toBeNull(); + + await page.context().setOffline(false); + await expect(page.locator('#username')).toBeVisible({ timeout: 90_000 }); + await expectStoreGone(page); + await expect.poll(async () => (await devicesOf(admin, ENT.carol))[0].wipedAt, { timeout: 30_000 }).not.toBeNull(); + + await Promise.all([page.context().close(), admin.close()]); +}); + +test('the user removes everything from this device, from settings', async ({ browser }) => { + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.bob); + const page = await userPage(browser, ENT.bob); + // Switched on and no more: this account has a lot of mail, so the wipe lands in + // the middle of its first sync, with the worker busy writing. + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect.poll(async () => (await rawStorage(page)).keyRecords, { timeout: 60_000 }).toBe(1); + await expect(page.getByTestId('offline-status')).toBeVisible({ timeout: 30_000 }); + const device = await theDevice(admin, ENT.bob); + + await page.getByTestId('wipe-device-button').scrollIntoViewIfNeeded(); + await page.getByTestId('wipe-device-button').click(); + const details = page.getByTestId('wipe-device-details'); + await expect(details).toContainText('S/MIME and OpenPGP keys'); + await expect(details).toContainText('All accounts are signed out'); + await shot(page, 'desktop-offline-remove-everything'); + // Nothing has happened yet. + expect((await rawStorage(page)).keyRecords).toBe(1); + await page.getByRole('button', { name: 'Remove everything', exact: true }).last().click(); + + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + await expectStoreGone(page); + await expectSignedOut(page); + await expect.poll(async () => (await devicesOf(admin, ENT.bob)).find((row) => row.id === device.id)?.wipeReason, { timeout: 30_000 }).toBe('user'); + + await Promise.all([page.context().close(), admin.close()]); +}); + +test('an account without offline mode has no row', async ({ browser }) => { + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.carol); + const page = await userPage(browser, ENT.carol); + await page.waitForTimeout(3_000); + expect(await devicesOf(admin, ENT.carol)).toEqual([]); + await Promise.all([page.context().close(), admin.close()]); +}); diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index c0d5edf9a..b0c07c7ec 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -243,6 +243,7 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [ sections: [ { id: 'devices', title: 'Downloads, printing and notifications', keywords: 'download attachment export print watermark install prompt push sender subject' }, { id: 'offline', title: 'Offline mode', keywords: 'offline cache device keep mail outbox' }, + { id: 'registered', title: 'Registered devices', keywords: 'device registry wipe remote lost stolen laptop remove desktop app browser' }, ], }, { diff --git a/lib/devices/__tests__/client.test.ts b/lib/devices/__tests__/client.test.ts new file mode 100644 index 000000000..6be4a6225 --- /dev/null +++ b/lib/devices/__tests__/client.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { checkInWithInstance, describePlatform, registerWithInstance, reportWipeToInstance } from '../client'; + +const record = { id: `${'a'.repeat(64)}/${'b'.repeat(32)}`, secret: 'c'.repeat(64) }; + +function answer(status: number, body: string, type = 'application/json'): void { + vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status, headers: { 'Content-Type': type } }))); +} + +afterEach(() => { + vi.unstubAllGlobals(); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('a check-in', () => { + it('hears a wipe only from a clear yes', async () => { + answer(200, JSON.stringify({ wipe: { requestedAt: '2026-11-03T00:00:00Z' } })); + expect(await checkInWithInstance(record)).toBe('wipe'); + }); + + it('reads no wipe as clear', async () => { + answer(200, JSON.stringify({ wipe: null })); + expect(await checkInWithInstance(record)).toBe('clear'); + }); + + it('reads the route\'s own 404 as a forgotten row', async () => { + answer(404, JSON.stringify({ error: 'unknown_device' })); + expect(await checkInWithInstance(record)).toBe('forgotten'); + }); + + it.each([ + ['an older instance\'s 404', 404, 'Not found', 'text/html'], + ['a 500', 500, JSON.stringify({ error: 'Internal server error' }), 'application/json'], + ['a 503', 503, 'Bad gateway', 'text/html'], + ['a 200 from something else', 200, 'Login', 'text/html'], + ['a 200 with other JSON', 200, JSON.stringify({ ok: true }), 'application/json'], + ['a 401', 401, JSON.stringify({ wipe: { requestedAt: 'x' } }), 'application/json'], + ])('reads %s as nothing', async (_name, status, body, type) => { + answer(status, body, type); + expect(await checkInWithInstance(record)).toBe('unavailable'); + }); + + it('reads no network as nothing', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + expect(await checkInWithInstance(record)).toBe('unavailable'); + }); +}); + +describe('registering', () => { + it('names the account\'s cookie slot', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(record), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + expect(await registerWithInstance(3)).toEqual(record); + const init = (fetchMock.mock.calls as unknown as [string, RequestInit][])[0][1]; + expect((init.headers as Record)['X-JMAP-Cookie-Slot']).toBe('3'); + }); + + it('gives null when it can\'t', async () => { + answer(401, '{}'); + expect(await registerWithInstance(0)).toBeNull(); + }); +}); + +describe('reporting a wipe', () => { + const entry = { ...record, reason: 'admin' as const, unsent: 1 }; + + it('is done on a yes, and when the row is gone', async () => { + answer(200, JSON.stringify({ ok: true })); + expect(await reportWipeToInstance(entry)).toBe(true); + answer(404, JSON.stringify({ error: 'unknown_device' })); + expect(await reportWipeToInstance(entry)).toBe(true); + }); + + it('tries again after anything else', async () => { + answer(500, '{}'); + expect(await reportWipeToInstance(entry)).toBe(false); + answer(404, ''); + expect(await reportWipeToInstance(entry)).toBe(false); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + expect(await reportWipeToInstance(entry)).toBe(false); + }); +}); + +describe('what a row calls the device', () => { + it('names the desktop app', () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + expect(describePlatform()).toBe('Desktop app, Windows'); + }); + + it('names a browser', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0 Safari/537.36'); + expect(describePlatform()).toBe('Chrome, Windows'); + }); +}); diff --git a/lib/devices/__tests__/registry.test.ts b/lib/devices/__tests__/registry.test.ts new file mode 100644 index 000000000..5c2afe014 --- /dev/null +++ b/lib/devices/__tests__/registry.test.ts @@ -0,0 +1,177 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeSharedStore } from '@/lib/shared-store/__tests__/fake-shared'; +import { setStoreForTests } from '@/lib/shared-store'; + +type Registry = typeof import('../registry'); + +let dir: string; +const info = { platform: 'Chrome, Windows', appVersion: '1.2.3' }; + +/** A second server process: its own copy of every module, the one store in common. */ +async function instance(shared: ReturnType): Promise { + vi.resetModules(); + const store = await import('@/lib/shared-store'); + store.setStoreForTests(shared); + return import('../registry'); +} + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'bw-devices-')); + vi.stubEnv('ADMIN_STATE_DIR', dir); +}); + +afterEach(() => { + setStoreForTests(null); + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('two server instances sharing the store', () => { + it('see one registry: a wipe asked on one reaches the device through the other, and the confirmation comes back', async () => { + const shared = fakeSharedStore(); + const one = await instance(shared); + const two = await instance(shared); + expect(two).not.toBe(one); + + const { id, secret } = await one.registerDevice('alice@example.org', 'https://mail.example.org', info); + const listed = await two.listDevices(); + expect(listed.total).toBe(1); + expect(listed.devices[0]).toMatchObject({ id, user: 'alice@example.org', platform: 'Chrome, Windows', appVersion: '1.2.3', wipe: null }); + + expect((await one.checkInDevice(id, secret, info))?.wipe).toBeNull(); + await two.requestDeviceWipe(id, 'admin'); + expect((await one.checkInDevice(id, secret, info))?.wipe).toMatchObject({ by: 'admin' }); + + await one.confirmDeviceWipe(id, secret, 'admin'); + const after = (await two.listDevices()).devices[0]; + expect(after.wipe).toBeNull(); + expect(after.wipedAt).not.toBeNull(); + expect(after.wipeReason).toBe('admin'); + }); + + it('keeps writes from both when they answer at once', async () => { + const shared = fakeSharedStore(); + const one = await instance(shared); + const two = await instance(shared); + const { id, secret } = await one.registerDevice('alice@example.org', 'https://mail.example.org', info); + await Promise.all([ + two.requestDeviceWipe(id, 'admin'), + one.checkInDevice(id, secret, { ...info, appVersion: '1.2.4' }), + ]); + const device = await two.getDevice(id); + expect(device?.wipe).toMatchObject({ by: 'admin' }); + expect(device?.appVersion).toBe('1.2.4'); + }); +}); + +describe('a local store', () => { + it('keeps a pending wipe through a restart, in the state dir, with no expiry', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + expect(registry.DEVICES_COLLECTION.ttlMs).toBeUndefined(); + + // A new process reads the file. + vi.resetModules(); + const restarted: Registry = await import('../registry'); + expect((await restarted.checkInDevice(id, secret, info))?.wipe).toMatchObject({ by: 'admin' }); + expect(readdirSync(path.join(dir, 'devices'), { recursive: true }).length).toBeGreaterThan(0); + }); + + it('stores only a hash of the secret, and never shows that either', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const file = readFileSync(path.join(dir, 'devices', `${id}.json`), 'utf8'); + expect(file).not.toContain(secret); + expect(JSON.stringify(await registry.listDevices())).not.toContain('secretHash'); + }); +}); + +describe('the rules', () => { + it('a wrong secret reads as no such device and changes nothing', async () => { + const registry: Registry = await import('../registry'); + const { id } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + expect(await registry.checkInDevice(id, 'f'.repeat(64), info)).toBeNull(); + expect(await registry.confirmDeviceWipe(id, 'f'.repeat(64), 'admin')).toBeNull(); + expect((await registry.getDevice(id))?.wipe).not.toBeNull(); + }); + + it('a device that never answers keeps its pending wipe, also past the age where others go', async () => { + const registry: Registry = await import('../registry'); + const waiting = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const idle = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(waiting.id, 'admin'); + + const later = Date.now() + registry.DEVICE_PRUNE_MS + 1000; + const { devices } = await registry.listDevices({ now: later }); + expect(devices.map((device) => device.id)).toEqual([waiting.id]); + expect(await registry.getDevice(idle.id)).toBeNull(); + expect((await registry.getDevice(waiting.id))?.wipe).not.toBeNull(); + }); + + it('one user\'s wipe reaches all of that user\'s devices and no one else\'s', async () => { + const registry: Registry = await import('../registry'); + const first = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const second = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const other = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + + const asked = await registry.requestUserWipe(first.id.split('/')[0], 'admin'); + expect(asked.map((device) => device.id).sort()).toEqual([first.id, second.id].sort()); + expect((await registry.getDevice(other.id))?.wipe).toBeNull(); + }); + + it('asks only once, and not of a device that already confirmed', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + expect(await registry.requestDeviceWipe(id, 'admin')).not.toBeNull(); + expect(await registry.requestDeviceWipe(id, 'admin')).toBeNull(); + await registry.confirmDeviceWipe(id, secret, 'admin'); + expect(await registry.requestDeviceWipe(id, 'admin')).toBeNull(); + }); + + it('a wipe taken back no longer reaches the device', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + await registry.cancelDeviceWipe(id); + expect((await registry.checkInDevice(id, secret, info))?.wipe).toBeNull(); + }); + + it('a check-in soon after the last one doesn\'t write again, a later one does', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const first = (await registry.getDevice(id))!.lastSeen; + const soon = await registry.checkInDevice(id, secret, info, Date.parse(first) + 1000); + expect(soon?.lastSeen).toBe(first); + const later = await registry.checkInDevice(id, secret, info, Date.parse(first) + 60 * 60 * 1000); + expect(later?.lastSeen).not.toBe(first); + }); + + it('caps a user\'s rows by dropping the oldest without a wipe waiting', async () => { + const registry: Registry = await import('../registry'); + const oldest = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(oldest.id, 'admin'); + const next = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + for (let i = 2; i < registry.MAX_DEVICES_PER_USER; i += 1) { + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + } + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const ids = (await registry.listDevices({ limit: 200 })).devices.map((device) => device.id); + expect(ids).toHaveLength(registry.MAX_DEVICES_PER_USER); + expect(ids).toContain(oldest.id); + expect(ids).not.toContain(next.id); + }); + + it('finds a user by part of the address', async () => { + const registry: Registry = await import('../registry'); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + const { devices, total } = await registry.listDevices({ query: 'ALICE' }); + expect(total).toBe(1); + expect(devices[0].user).toBe('alice@example.org'); + }); +}); diff --git a/lib/devices/client.ts b/lib/devices/client.ts new file mode 100644 index 000000000..160f6a2d1 --- /dev/null +++ b/lib/devices/client.ts @@ -0,0 +1,90 @@ +/** + * This device's calls to the instance's registry. Only a clear answer counts: + * a wipe needs a 200 with a `wipe` in it, and "forgotten" needs the route's own + * word. An older instance without the routes, a proxy's error page or no + * network at all reads as `unavailable`, and nothing happens. + */ + +import { apiFetch } from '@/lib/browser-navigation'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { DeviceRecord } from '@/lib/store/client/keys'; +import type { WipeConfirmation } from './wipe-state'; + +export type CheckInAnswer = 'wipe' | 'clear' | 'forgotten' | 'unavailable'; + +/** Short enough for one row in a list. Nothing identifying beyond what a browser sends anyway. */ +export function describePlatform(): string { + const bridge = getDesktopBridge(); + if (bridge) return `Desktop app, ${({ win32: 'Windows', darwin: 'macOS', linux: 'Linux' } as const)[bridge.platform] ?? bridge.platform}`; + if (typeof navigator === 'undefined') return ''; + const ua = navigator.userAgent; + const os = /Android/.test(ua) ? 'Android' + : /iPhone|iPad/.test(ua) ? 'iOS' + : /Windows/.test(ua) ? 'Windows' + : /Mac OS X/.test(ua) ? 'macOS' + : /Linux/.test(ua) ? 'Linux' + : 'Unknown system'; + const browser = /Edg\//.test(ua) ? 'Edge' + : /Firefox\//.test(ua) ? 'Firefox' + : /Chrome\//.test(ua) ? 'Chrome' + : /Safari\//.test(ua) ? 'Safari' + : 'Browser'; + return `${browser}, ${os}`; +} + +function deviceInfo() { + return { platform: describePlatform(), appVersion: process.env.NEXT_PUBLIC_APP_VERSION ?? '' }; +} + +async function readJson(response: Response): Promise | null> { + try { + const body = await response.clone().json() as unknown; + return body && typeof body === 'object' ? body as Record : null; + } catch { + return null; + } +} + +function post(path: string, body: unknown, signal?: AbortSignal, headers: Record = {}): Promise { + return apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + signal, + }); +} + +/** A new row, for the account signed in on this cookie slot. Null if that can't be done now. */ +export async function registerWithInstance(slot: number, signal?: AbortSignal): Promise<{ id: string; secret: string } | null> { + try { + const response = await post('/api/devices', deviceInfo(), signal, { 'X-JMAP-Cookie-Slot': String(slot) }); + if (!response.ok) return null; + const body = await readJson(response); + return typeof body?.id === 'string' && typeof body.secret === 'string' ? { id: body.id, secret: body.secret } : null; + } catch { + return null; + } +} + +export async function checkInWithInstance(record: Pick, signal?: AbortSignal): Promise { + try { + const response = await post('/api/devices', { id: record.id, secret: record.secret, ...deviceInfo() }, signal); + const body = await readJson(response); + if (response.status === 404) return body?.error === 'unknown_device' ? 'forgotten' : 'unavailable'; + if (!response.ok || !body || !('wipe' in body)) return 'unavailable'; + return body.wipe && typeof body.wipe === 'object' ? 'wipe' : 'clear'; + } catch { + return 'unavailable'; + } +} + +/** True when there's nothing more to say: it was taken, or the row no longer exists. */ +export async function reportWipeToInstance(entry: WipeConfirmation, signal?: AbortSignal): Promise { + try { + const response = await post('/api/devices/wiped', entry, signal); + if (response.ok) return true; + return response.status === 404 && (await readJson(response))?.error === 'unknown_device'; + } catch { + return false; + } +} diff --git a/lib/devices/registry.ts b/lib/devices/registry.ts new file mode 100644 index 000000000..b7cea30b9 --- /dev/null +++ b/lib/devices/registry.ts @@ -0,0 +1,231 @@ +/** + * The devices that keep mail for this instance: browsers with offline mode on + * and desktop apps. One durable document per device and account, so every + * replica sees the same list and a pending wipe outlives a restart. + * + * No `ttlMs`: a machine that stays off for a year still has its wipe waiting. + * Old documents without a pending wipe go through `pruneDevices`. + * + * A check-in proves itself with the device's secret, not with a session, and + * nothing here asks the mail server. A device whose password was revoked has + * to be able to learn about its wipe. The secret only lets a device read and + * answer for its own row. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { getStatePath } from '@/lib/admin/paths'; +import { settingsId } from '@/lib/settings-sync'; +import { getStore, type CollectionOptions } from '@/lib/shared-store'; +import type { DeviceView, WipeReason } from './types'; + +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}$/; +const DEVICE_FILE_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}\.json$/; + +/** A user with more rows than this loses the oldest one that has no wipe waiting. */ +export const MAX_DEVICES_PER_USER = 100; +/** Not seen for this long and no wipe waiting: forgotten by the next admin listing. */ +export const DEVICE_PRUNE_MS = 180 * 24 * 60 * 60 * 1000; +/** A check-in within this time of the last write doesn't write again. */ +const LAST_SEEN_WRITE_MS = 5 * 60 * 1000; +const READ_CONCURRENCY = 50; + +export const DEVICES_COLLECTION: CollectionOptions = { + local: { + dir: () => getStatePath('devices'), + file: (id) => `${id}.json`, + idOf: (relative) => (DEVICE_FILE_RE.test(relative) ? relative.slice(0, -'.json'.length) : null), + mode: 0o600, + }, +}; + +interface StoredDevice extends Omit { + secretHash: string; +} + +function collection() { + return getStore().collection('devices', DEVICES_COLLECTION); +} + +function hashSecret(secret: string): string { + return createHash('sha256').update(secret).digest('hex'); +} + +function secretMatches(device: StoredDevice, secret: string): boolean { + const given = Buffer.from(hashSecret(secret)); + const stored = Buffer.from(device.secretHash); + return given.length === stored.length && timingSafeEqual(given, stored); +} + +function parse(data: Buffer | null | undefined): StoredDevice | null { + if (!data) return null; + try { + return JSON.parse(data.toString('utf8')) as StoredDevice; + } catch { + return null; + } +} + +function toView(id: string, device: StoredDevice): DeviceView { + const { secretHash: _secretHash, ...rest } = device; + return { id, userKey: id.split('/')[0], ...rest }; +} + +async function readDevice(id: string): Promise { + return parse((await collection().get(id))?.data); +} + +async function readMany(ids: string[]): Promise> { + const found: Array<[string, StoredDevice]> = []; + for (let start = 0; start < ids.length; start += READ_CONCURRENCY) { + const batch = ids.slice(start, start + READ_CONCURRENCY); + const devices = await Promise.all(batch.map((id) => readDevice(id))); + devices.forEach((device, index) => { + if (device) found.push([batch[index], device]); + }); + } + return found; +} + +async function makeRoomFor(userKey: string): Promise { + const ids = await collection().list(`${userKey}/`); + if (ids.length < MAX_DEVICES_PER_USER) return; + const removable = (await readMany(ids)) + .filter(([, device]) => !device.wipe) + .sort(([, a], [, b]) => a.lastSeen.localeCompare(b.lastSeen)); + for (const [id] of removable.slice(0, ids.length - MAX_DEVICES_PER_USER + 1)) await collection().delete(id); +} + +export interface DeviceInfo { + platform: string; + appVersion: string; +} + +/** A new row for this browser profile and account. The secret goes back to the device once and is kept only as a hash. */ +export async function registerDevice(user: string, server: string, info: DeviceInfo): Promise<{ id: string; secret: string }> { + const userKey = settingsId(user, server); + await makeRoomFor(userKey); + const id = `${userKey}/${randomBytes(16).toString('hex')}`; + const secret = randomBytes(32).toString('hex'); + const now = new Date().toISOString(); + const device: StoredDevice = { + user, + server, + platform: info.platform, + appVersion: info.appVersion, + firstSeen: now, + lastSeen: now, + wipe: null, + wipedAt: null, + wipeReason: null, + secretHash: hashSecret(secret), + }; + await collection().put(id, JSON.stringify(device), { ifVersion: null }); + return { id, secret }; +} + +/** Null when the row is gone or the secret doesn't fit. Both read the same to the caller. */ +export async function checkInDevice(id: string, secret: string, info: DeviceInfo, now = Date.now()): Promise { + const current = await readDevice(id); + if (!current || !secretMatches(current, secret)) return null; + const stale = now - Date.parse(current.lastSeen) >= LAST_SEEN_WRITE_MS; + if (!stale && current.platform === info.platform && current.appVersion === info.appVersion) return toView(id, current); + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device) return undefined; + return JSON.stringify({ ...device, ...info, lastSeen: new Date(now).toISOString() }); + }); + const device = parse(data); + return device ? toView(id, device) : toView(id, current); +} + +/** The device says it wiped. Null when the row is gone or the secret doesn't fit. */ +export async function confirmDeviceWipe(id: string, secret: string, reason: WipeReason, now = Date.now()): Promise { + const current = await readDevice(id); + if (!current || !secretMatches(current, secret)) return null; + const at = new Date(now).toISOString(); + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device) return undefined; + return JSON.stringify({ ...device, wipe: null, wipedAt: at, wipeReason: reason, lastSeen: at }); + }); + const device = parse(data); + return device ? toView(id, device) : null; +} + +/** Null when nothing changed: no such row, a wipe already waiting, or a row that confirmed and has nothing left. */ +export async function requestDeviceWipe(id: string, by: string, now = Date.now()): Promise { + const requestedAt = new Date(now).toISOString(); + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device || device.wipedAt || device.wipe) return undefined; + return JSON.stringify({ ...device, wipe: { requestedAt, by } }); + }); + const device = parse(data); + return device ? toView(id, device) : null; +} + +/** Every device of a user that hasn't wiped already, one request each. */ +export async function requestUserWipe(userKey: string, by: string, now = Date.now()): Promise { + const ids = await collection().list(`${userKey}/`); + const results = await Promise.all(ids.map((id) => requestDeviceWipe(id, by, now))); + return results.filter((device): device is DeviceView => device !== null); +} + +export async function cancelDeviceWipe(id: string): Promise { + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device?.wipe) return undefined; + return JSON.stringify({ ...device, wipe: null }); + }); + const device = parse(data); + return device ? toView(id, device) : null; +} + +export async function forgetDevice(id: string): Promise { + return collection().delete(id); +} + +export async function getDevice(id: string): Promise { + const device = await readDevice(id); + return device ? toView(id, device) : null; +} + +export function isUserKey(value: unknown): value is string { + return typeof value === 'string' && USER_KEY_RE.test(value); +} + +export interface DevicePage { + devices: DeviceView[]; + total: number; +} + +/** One page, by user then device. `query` looks through every row, so it reads them all. */ +export async function listDevices(options: { offset?: number; limit?: number; query?: string; now?: number } = {}): Promise { + const { offset = 0, limit = 100, query, now = Date.now() } = options; + const ids = await collection().list(''); + const needle = query?.trim().toLowerCase(); + if (!needle) { + const page = await readMany(ids.slice(offset, offset + limit)); + const kept = await pruned(page, now); + return { devices: kept.map(([id, device]) => toView(id, device)), total: ids.length }; + } + const everything = await pruned(await readMany(ids), now); + const matching = everything.filter(([, device]) => device.user.toLowerCase().includes(needle)); + return { + devices: matching.slice(offset, offset + limit).map(([id, device]) => toView(id, device)), + total: matching.length, + }; +} + +/** What the admin is about to see: old rows go, a row with a wipe waiting never does. */ +async function pruned(rows: Array<[string, StoredDevice]>, now: number): Promise> { + const kept: Array<[string, StoredDevice]> = []; + for (const row of rows) { + const [id, device] = row; + if (!device.wipe && now - Date.parse(device.lastSeen) > DEVICE_PRUNE_MS) await collection().delete(id); + else kept.push(row); + } + return kept; +} diff --git a/lib/devices/types.ts b/lib/devices/types.ts new file mode 100644 index 000000000..367b6bef7 --- /dev/null +++ b/lib/devices/types.ts @@ -0,0 +1,30 @@ +/** + * The device registry as both sides see it: what the instance lists for its + * 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; +export type WipeReason = (typeof WIPE_REASONS)[number]; + +export interface DeviceView { + /** `/`. */ + id: string; + /** The first part of `id`: every device of this user on this server. */ + userKey: string; + user: string; + server: string; + platform: string; + appVersion: string; + firstSeen: string; + lastSeen: string; + /** Asked for and not confirmed yet. Stays until the device answers or the admin takes it back. */ + wipe: { requestedAt: string; by: string } | null; + wipedAt: string | null; + wipeReason: WipeReason | null; +} + +export const AUDIT_DEVICE_WIPE_REQUEST = 'device.wipe.request'; +export const AUDIT_DEVICE_WIPE_CANCEL = 'device.wipe.cancel'; +export const AUDIT_DEVICE_WIPE_CONFIRM = 'device.wipe.confirm'; +export const AUDIT_DEVICE_FORGET = 'device.forget'; diff --git a/lib/devices/wipe-state.ts b/lib/devices/wipe-state.ts new file mode 100644 index 000000000..72ab7b653 --- /dev/null +++ b/lib/devices/wipe-state.ts @@ -0,0 +1,135 @@ +/** + * What a wipe has to remember in localStorage, in a store-free module the auth + * store can import. Nothing here names a subject, and what stays after a wipe + * is a count, a row id and the secret for that one row. + */ + +import type { WipeReason } from './types'; + +const PENDING_KEY = 'bulwark-wipe-pending'; +const CONFIRM_KEY = 'bulwark-wipe-confirm'; +const NOTE_KEY = 'bulwark-wipe-note'; +const REFUSED_KEY = 'bulwark-credentials-refused'; +const DATABASES_KEY = 'bulwark-wipe-databases'; + +/** The pending entry for a wipe of everything on this device. */ +export const WHOLE_DEVICE = '*'; + +/** + * A 401 after re-auth has to last this long before the login counts as refused. + * Two answers a moment apart can be one bad moment on the server. + */ +export const CREDENTIALS_REFUSED_CONFIRM_MS = 60_000; + +/** + * How long a refused login's data waits for a sign-in with the new password. + * Nothing but this 401 says the login is over, since a password login has no + * `invalid_grant`. A server being restored from a backup says the same for hours, + * and someone who changed their password elsewhere is about to type the new one. + */ +export const REFUSED_KEEP_MS = 24 * 60 * 60 * 1000; + +function readJson(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +function writeJson(key: string, value: unknown, empty: boolean): void { + try { + if (empty) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* storage unavailable */ + } +} + +// ---- A wipe under way: finished at the next page load if this one dies ---- + +export function readPendingWipes(): Record { + return readJson>(PENDING_KEY, {}); +} + +export function markWipePending(account: string, reason: WipeReason): void { + writeJson(PENDING_KEY, { ...readPendingWipes(), [account]: reason }, false); +} + +export function clearWipePending(account: string): void { + const pending = readPendingWipes(); + delete pending[account]; + writeJson(PENDING_KEY, pending, Object.keys(pending).length === 0); +} + +// ---- Telling the instance: sent until it gets through ---- + +export interface WipeConfirmation { + id: string; + secret: string; + reason: WipeReason; + unsent: number; +} + +export function readWipeConfirmations(): WipeConfirmation[] { + const list = readJson(CONFIRM_KEY, []); + return Array.isArray(list) ? list : []; +} + +export function queueWipeConfirmation(entry: WipeConfirmation): void { + writeJson(CONFIRM_KEY, [...readWipeConfirmations().filter((other) => other.id !== entry.id), entry], false); +} + +export function dropWipeConfirmation(id: string): void { + const rest = readWipeConfirmations().filter((entry) => entry.id !== id); + writeJson(CONFIRM_KEY, rest, rest.length === 0); +} + +// ---- The note at the next sign-in: a count, never subjects ---- + +export function addWipeNote(unsent: number): void { + const before = readJson<{ unsent?: number }>(NOTE_KEY, {}); + writeJson(NOTE_KEY, { unsent: (before.unsent ?? 0) + unsent }, false); +} + +/** Once: whoever shows it takes it. Null when no wipe left a word. */ +export function consumeWipeNote(): { unsent: number } | null { + const note = readJson<{ unsent?: number } | null>(NOTE_KEY, null); + writeJson(NOTE_KEY, null, true); + return note && typeof note.unsent === 'number' ? { unsent: note.unsent } : null; +} + +// ---- A refused password: when the wait started ---- + +export function readRefusedLogins(): Record { + return readJson>(REFUSED_KEY, {}); +} + +export function markLoginRefused(account: string, now = Date.now()): void { + writeJson(REFUSED_KEY, { ...readRefusedLogins(), [account]: now }, false); +} + +export function dropRefusedLogin(account: string): void { + const refused = readRefusedLogins(); + delete refused[account]; + writeJson(REFUSED_KEY, refused, Object.keys(refused).length === 0); +} + +// ---- Plugin databases a running frame kept open: tried again at the next load ---- + +export function readBlockedDatabases(): Record { + return readJson>(DATABASES_KEY, {}); +} + +export function writeBlockedDatabases(blocked: Record): void { + writeJson(DATABASES_KEY, blocked, Object.keys(blocked).length === 0); +} + +/** Whether anything is left over from an earlier wipe, so a start must look. */ +export function wipeWorkWaiting(): boolean { + return Object.keys(readPendingWipes()).length > 0 + || readWipeConfirmations().length > 0 + || Object.keys(readRefusedLogins()).length > 0 + || Object.keys(readBlockedDatabases()).length > 0; +} diff --git a/lib/jmap/__tests__/credentials-refused.test.ts b/lib/jmap/__tests__/credentials-refused.test.ts new file mode 100644 index 000000000..928915ed7 --- /dev/null +++ b/lib/jmap/__tests__/credentials-refused.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../client'; + +// A 401 after a fresh try at signing in is the one signal a password login has +// that its credentials are over. So what else looks like one must not count. + +const SERVER = 'https://mail.example.test'; +const problem = () => new Response(JSON.stringify({ type: 'about:blank', status: 401, title: 'Unauthorized', detail: 'You have to authenticate first.' }), { status: 401, headers: { 'Content-Type': 'application/problem+json' } }); +const html401 = () => new Response('Sign in to the proxy', { status: 401, headers: { 'Content-Type': 'text/html' } }); +const session = () => new Response(JSON.stringify({ + apiUrl: `${SERVER}/jmap`, downloadUrl: `${SERVER}/d`, uploadUrl: `${SERVER}/u`, eventSourceUrl: '', username: 'alice', + capabilities: { 'urn:ietf:params:jmap:core': {} }, accounts: { a: { name: 'alice', isPersonal: true, accountCapabilities: {} } }, primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, +}), { status: 200, headers: { 'Content-Type': 'application/json' } }); +const echo = () => new Response(JSON.stringify({ methodResponses: [['Core/echo', { ping: 'pong' }, '0']], sessionState: 's' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + +let answers: { api: () => Response; session: () => Response }; + +function stubServer(): void { + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => (String(input).includes('/.well-known/jmap') ? answers.session() : answers.api()))); +} + +async function connectedBasic(): Promise<{ client: JMAPClient; refused: number[] }> { + answers = { session, api: echo }; + const client = new JMAPClient(SERVER, 'alice', 'secret'); + await client.connect(); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + return { client, refused }; +} + +beforeEach(() => { + vi.useRealTimers(); + stubServer(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('a password login', () => { + it('counts when the session URL refuses too', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: problem, session: problem }; + await client.ping().catch(() => {}); + expect(refused).toHaveLength(1); + client.disconnect(); + }); + + it('counts when the session answers and the retry is refused', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: problem, session }; + await client.ping().catch(() => {}); + expect(refused).toHaveLength(1); + client.disconnect(); + }); + + it('doesn\'t count when the session URL gives a 500: that says nothing about the password', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: problem, session: () => new Response('oops', { status: 500 }) }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('doesn\'t count when the network breaks on the way', async () => { + const { client, refused } = await connectedBasic(); + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + if (String(input).includes('/.well-known/jmap')) throw new TypeError('Failed to fetch'); + return problem(); + })); + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('doesn\'t count a proxy\'s page', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: html401, session: html401 }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('doesn\'t count a plain 500, or a 403', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: () => new Response('nope', { status: 500 }), session }; + await client.ping().catch(() => {}); + answers = { api: () => new Response(JSON.stringify({ status: 403 }), { status: 403 }), session }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('counts at connect, so a restore can tell', async () => { + answers = { api: echo, session: problem }; + const client = new JMAPClient(SERVER, 'alice', 'secret'); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + await expect(client.connect()).rejects.toThrow('Invalid username or password'); + expect(refused).toHaveLength(1); + }); + + it('never counts with a one-time code: those go stale by themselves', async () => { + const { client, refused } = await connectedBasic(); + client.enableTotpReauth('secret', async () => null); + answers = { api: problem, session: problem }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('measures from the first refusal, and a good answer starts it over', async () => { + const { client, refused } = await connectedBasic(); + const now = vi.spyOn(Date, 'now'); + now.mockReturnValue(1_000_000); + answers = { api: problem, session: problem }; + await client.ping().catch(() => {}); + now.mockReturnValue(1_090_000); + await client.ping().catch(() => {}); + expect(refused).toEqual([0, 90_000]); + + answers = { api: echo, session }; + now.mockReturnValue(1_100_000); + await client.ping(); + answers = { api: problem, session: problem }; + now.mockReturnValue(1_200_000); + await client.ping().catch(() => {}); + expect(refused).toEqual([0, 90_000, 0]); + client.disconnect(); + }); +}); + +describe('a token login', () => { + function bearer(renewed: string | null): { client: JMAPClient; refused: number[]; renew: ReturnType } { + const renew = vi.fn(async () => renewed); + const client = JMAPClient.withBearer(SERVER, 'old', 'alice', renew); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + return { client, refused, renew }; + } + + it('counts when a token issued just now is refused', async () => { + answers = { api: problem, session: problem }; + const { client, refused, renew } = bearer('new'); + await expect(client.connect()).rejects.toThrow(); + expect(renew).toHaveBeenCalled(); + expect(refused).toHaveLength(1); + }); + + it('doesn\'t count when no new token came: the renewal has its own rules', async () => { + answers = { api: problem, session: problem }; + const { client, refused } = bearer(null); + await expect(client.connect()).rejects.toThrow(); + expect(refused).toEqual([]); + }); + + it('counts for a pasted token, which nothing can renew', async () => { + answers = { api: problem, session: problem }; + const client = JMAPClient.withBearer(SERVER, 'pasted', 'alice'); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + await expect(client.connect()).rejects.toThrow(); + expect(refused).toHaveLength(1); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 487b060e7..766631e37 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -171,6 +171,12 @@ export interface IJMAPClient { hasSSEStream(): boolean; onConnectionChange(callback: (connected: boolean) => void): void; onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void; + /** + * The mail server itself answered 401 after a fresh try at signing in, with + * `refusedForMs` since it first did with no good answer in between. Not called + * for an outage, a proxy's page or a token renewal that failed. + */ + onCredentialsRefused?(callback: (refusedForMs: number) => void): void; isRateLimited(): boolean; getRateLimitRemainingMs(): number; onStateChange(callback: (change: StateChange) => void): void; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 1996dab17..ffe3b7c4d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1620,6 +1620,9 @@ export class JMAPClient implements IJMAPClient { private lastStates: AccountStates = {}; private reconnecting = false; private connectionChangeCallback: ((connected: boolean) => void) | null = null; + private credentialsRefusedCallback: ((refusedForMs: number) => void) | null = null; + /** When the server first refused us with no good answer since; 0 when it hasn't. */ + private refusedSince = 0; private rateLimitedUntil: number = 0; private rateLimitCallback: ((rateLimited: boolean, retryAfterMs: number) => void) | null = null; private rateLimitTimeout: NodeJS.Timeout | null = null; @@ -1907,6 +1910,7 @@ export class JMAPClient implements IJMAPClient { throw new RateLimitError(retryAfterMs); } + let reauthRefused = false; if (response.status === 401) { if (this.authMode === 'bearer' && this.onTokenRefresh) { const newToken = await this.onTokenRefresh(); @@ -1914,7 +1918,13 @@ export class JMAPClient implements IJMAPClient { this.updateAccessToken(newToken); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await this.timedFetch(url, init, retryHeaders, timeoutMs); + // A token issued just now was refused. No new token says nothing: + // the renewal has its own rules for that. + reauthRefused = true; } + } else if (this.authMode === 'bearer') { + // A pasted token: there's nothing to renew it with. + reauthRefused = true; } else if (this.authMode === 'basic' && !this.reconnecting && url !== this.discoveryUrl()) { // JMAP session may have expired - re-establish and retry once this.reconnecting = true; @@ -1923,7 +1933,11 @@ export class JMAPClient implements IJMAPClient { this.connectionChangeCallback?.(true); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await this.timedFetch(url, init, retryHeaders, timeoutMs); - } catch { + reauthRefused = true; + } catch (refreshError) { + // A 500 or a broken connection on the way says nothing against the + // password. Only the session URL's own 401 does. + reauthRefused = refreshError instanceof Error && refreshError.message === 'Session refresh failed: 401'; // Session refresh failed - if TOTP was used, try re-auth with fresh TOTP if (this.onTotpRequired && this.basePassword) { try { @@ -1945,10 +1959,34 @@ export class JMAPClient implements IJMAPClient { } } - if (response.status !== 401) this.authAcceptedAt = Date.now(); + if (response.status !== 401) { + this.authAcceptedAt = Date.now(); + this.refusedSince = 0; + } else if (reauthRefused && !response.redirected) { + await this.noteCredentialsRefused(response); + } return response; } + onCredentialsRefused(callback: (refusedForMs: number) => void): void { + this.credentialsRefusedCallback = callback; + } + + /** + * Only the mail server's own refusal counts: JMAP errors are problem documents + * (RFC 8620 section 3.6.1). A proxy or a login page in front of the server + * answers with something else, and says nothing about these credentials. + * A password with a one-time code goes stale by itself, so it never counts. + */ + private async noteCredentialsRefused(response: Response): Promise { + if (this.onTotpRequired || this.basePassword) return; + const body = await response.clone().json().catch(() => null) as { status?: unknown } | null; + if (body?.status !== 401) return; + const now = Date.now(); + if (!this.refusedSince) this.refusedSince = now; + this.credentialsRefusedCallback?.(now - this.refusedSince); + } + /** * Fetch the JMAP session, transparently handling servers that redirect * /.well-known/jmap to a canonical session URL (e.g. Stalwart → /jmap/session). @@ -2015,6 +2053,8 @@ export class JMAPClient implements IJMAPClient { if (!sessionResponse.ok) { if (sessionResponse.status === 401) { + // A token's refusal was counted on the way here, if it counts at all. + if (this.authMode === 'basic' && !sessionResponse.redirected) await this.noteCredentialsRefused(sessionResponse); throw new Error(this.authMode === 'bearer' ? 'Authentication failed - token may be expired' : 'Invalid username or password'); diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts new file mode 100644 index 000000000..a0bac109f --- /dev/null +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -0,0 +1,593 @@ +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// What a wipe removes and what makes one happen. Real auth store, real offline +// mode, the store's real engine, real IndexedDB (fake-indexeddb): only the +// network is a stub, and the OPFS pool, which jsdom doesn't have. + +vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })) })); +const purge = vi.hoisted(() => ({ files: 0 })); +vi.mock('@/lib/store/client/purge', async () => { + const keys = await vi.importActual('@/lib/store/client/keys'); + return { purgeStoreFiles: async () => { purge.files += 1; keys.deleteKeyDatabase(); }, deleteStorePool: async () => {} }; +}); + +// A store and a full sign-out in each test: slow on a machine doing other work. +vi.setConfig({ testTimeout: 60_000 }); + +const ALICE = 'alice@mail.example.test'; +const BOB = 'bob@mail.example.test'; +const ROW = `${'a'.repeat(64)}/${'b'.repeat(32)}`; +const SECRET = 'c'.repeat(64); +const BOB_ROW = `${'d'.repeat(64)}/${'e'.repeat(32)}`; + +let wipe: typeof import('../device-wipe'); +let useAuthStore: typeof import('@/stores/auth-store').useAuthStore; +let useAccountStore: typeof import('@/stores/account-store').useAccountStore; +let state: typeof import('../state'); +let keys: typeof import('@/lib/store/client/keys'); +let wipeState: typeof import('@/lib/devices/wipe-state'); +let rejected: typeof import('../credentials-rejected'); +let registry: typeof import('../wipe-registry'); +let queue: typeof import('../queue'); +let store: import('@/lib/store/__tests__/helpers/in-process-store').InProcessStore; + +type Respond = (url: string, init?: RequestInit) => Response | Promise; +let respond: Respond; +let sent: Array<{ url: string; body: Record; headers: Record }>; + +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +const noWipe = () => json(200, { wipe: null }); +const wipeNow = () => json(200, { wipe: { requestedAt: '2026-11-03T00:00:00.000Z' } }); + +function entry(id: string, cookieSlot: number) { + return { + id, label: id, serverUrl: 'https://mail.example.test', username: id.split('@mail')[0], authMode: 'basic', rememberMe: true, + displayName: id, email: id, lastLoginAt: 1, isConnected: true, hasError: false, isDefault: cookieSlot === 0, cookieSlot, avatarColor: '#000', + }; +} + +function openDb(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => { request.result.createObjectStore('s'); }; + request.onsuccess = () => { request.result.close(); resolve(); }; + request.onerror = () => reject(request.error); + }); +} + +async function databases(): Promise { + return (await indexedDB.databases()).map((database) => database.name ?? '').sort(); +} + +async function freshIndexedDb(): Promise { + for (const { name } of await indexedDB.databases()) { + if (name) await new Promise((resolve) => { const request = indexedDB.deleteDatabase(name); request.onsuccess = request.onerror = () => resolve(null); }); + } +} + +beforeEach(async () => { + vi.resetModules(); + purge.files = 0; + localStorage.clear(); + sessionStorage.clear(); + await freshIndexedDb(); + sent = []; + respond = () => noWipe(); + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/devices')) sent.push({ url, body: init?.body ? JSON.parse(String(init.body)) : {}, headers: (init?.headers ?? {}) as Record }); + return respond(url, init); + })); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + + ({ useAuthStore } = await import('@/stores/auth-store')); + ({ useAccountStore } = await import('@/stores/account-store')); + state = await import('../state'); + keys = await import('@/lib/store/client/keys'); + wipeState = await import('@/lib/devices/wipe-state'); + rejected = await import('../credentials-rejected'); + registry = await import('../wipe-registry'); + queue = await import('../queue'); + wipe = await import('../device-wipe'); + const browserNavigation = await import('@/lib/browser-navigation'); + vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + const { InProcessStore } = await import('@/lib/store/__tests__/helpers/in-process-store'); + const { setStoreBackend } = await import('@/lib/store/client/api'); + store = new InProcessStore(); + setStoreBackend(store); + + useAccountStore.setState({ accounts: [entry(ALICE, 0)], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: ALICE, authMode: 'basic', serverUrl: 'https://mail.example.test', username: 'alice' }); + state.writeEnabled([ALICE]); + await keys.openAccountKey(ALICE); + await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 }); +}, 60_000); + +/** The wipe goes on behind the start's budget, and takes its time under load. */ +async function waitForNoAccounts(): Promise { + await vi.waitFor(() => expect(useAccountStore.getState().accounts).toEqual([]), { timeout: 20_000 }); +} + +function expectUntouched(): void { + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([ALICE]); + expect(wipeState.readPendingWipes()).toEqual({}); +} + +describe('wipe on the next connect', () => { + beforeEach(() => { + respond = (url) => (url.endsWith('/api/devices/wiped') ? json(200, { ok: true }) : wipeNow()); + }); + + it('removes the store and every key, signs out, and tells the instance', async () => { + await queue.queueSend(ALICE, { subject: 'Secret plans', to: [] }, { kind: 'email', args: [] }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + + expect(purge.files).toBeGreaterThan(0); + expect(state.useOfflineStore.getState().enabled).toEqual([]); + expect(await databases()).not.toContain('bulwark-store'); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + + const report = sent.find((call) => call.url.endsWith('/api/devices/wiped')); + expect(report?.body).toMatchObject({ id: ROW, secret: SECRET, reason: 'admin', unsent: 1 }); + expect(wipeState.readWipeConfirmations()).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); + }); + + it('notes the count for the next sign-in, and no subject anywhere', async () => { + await queue.queueSend(ALICE, { subject: 'Secret plans', to: [] }, { kind: 'email', args: [] }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(JSON.stringify({ ...localStorage })).not.toContain('Secret plans'); + expect(JSON.stringify({ ...sessionStorage })).not.toContain('Secret plans'); + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 1 }); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + + it('leaves a note with no count when nothing was waiting', async () => { + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0 }); + }); + + it('deletes S/MIME and OpenPGP keys by name, plugin data and handoffs, keeping the plugins themselves', async () => { + for (const name of ['smime-plugin-store', 'bulwark-openpgp', 'made-by-some-plugin']) await openDb(name); + const { fileStorage } = await import('@/lib/plugin-storage'); + const clearFiles = vi.spyOn(fileStorage, 'clearFiles').mockResolvedValue(); + const { pluginStoragePrefix } = await import('@/lib/plugin-sandbox/storage-scope'); + localStorage.setItem(`${pluginStoragePrefix('notes', ALICE)}note`, 'mine'); + localStorage.setItem('notification-center', '[{"title":"From Eve"}]'); + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState({ plugins: [{ id: 'managed', managed: true, settings: { apiKey: 'k' } }] as never }); + const handoffs = await import('@/lib/launch-handoff'); + const clearHandoffs = vi.spyOn(handoffs, 'clearHandoffs'); + + await wipe.runDeviceGate(); + await waitForNoAccounts(); + + const left = await databases(); + for (const name of ['smime-plugin-store', 'bulwark-openpgp', 'made-by-some-plugin']) expect(left).not.toContain(name); + expect(localStorage.getItem(`${pluginStoragePrefix('notes', ALICE)}note`)).toBeNull(); + expect(localStorage.getItem('notification-center') ?? '[]').toBe('[]'); + expect(usePluginStore.getState().plugins[0].settings).toEqual({}); + expect(clearFiles).toHaveBeenCalled(); + expect(clearHandoffs).toHaveBeenCalled(); + }); + + it('runs what features registered, edit-in-place\'s files for one, with the account', async () => { + const cleaned: Array = []; + registry.onDeviceWipe(async (account) => { cleaned.push(account); }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(cleaned).toEqual([ALICE]); + }); + + it('a feature that fails or hangs doesn\'t stop the wipe', async () => { + registry.onDeviceWipe(() => { throw new Error('no'); }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(purge.files).toBeGreaterThan(0); + }); + + it('only takes the account it was told, and keeps the other\'s store and row', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + respond = (url, init) => { + if (url.endsWith('/api/devices/wiped')) return json(200, { ok: true }); + return JSON.parse(String(init?.body)).id === ROW ? wipeNow() : noWipe(); + }; + + await wipe.runDeviceGate(); + await vi.waitFor(() => expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([BOB])); + + expect(state.useOfflineStore.getState().enabled).toEqual([BOB]); + expect(store.removed).toContain(ALICE); + expect(store.removed).not.toContain(BOB); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); + }); + + it('after the last of several accounts goes, the rest keep their row', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + // Bob keeps nothing here but has a row: the desktop app, say. + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + respond = (url, init) => { + if (url.endsWith('/api/devices/wiped')) return json(200, { ok: true }); + return JSON.parse(String(init?.body)).id === ROW ? wipeNow() : noWipe(); + }; + await wipe.runDeviceGate(); + await vi.waitFor(() => expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([BOB])); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); + }); + + it('reaches an account dropped earlier, whose data waits and which has no cookies', async () => { + useAccountStore.setState({ accounts: [] } as never); + useAuthStore.setState({ isAuthenticated: false, activeAccountId: null }); + state.markEvicted(ALICE); + await wipe.runDeviceGate(); + await vi.waitFor(() => expect(state.useOfflineStore.getState().enabled).toEqual([])); + expect(purge.files).toBeGreaterThan(0); + expect(state.isEvicted(ALICE)).toBe(false); + }); +}); + +describe('the report', () => { + it('goes out again at the next contact when it failed, with no account and no cookies', async () => { + let reportWorks = false; + respond = (url) => { + if (url.endsWith('/api/devices/wiped')) return reportWorks ? json(200, { ok: true }) : json(503, {}); + return wipeNow(); + }; + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]); + + // A new page load, the sign-in page: everything else about the account is gone. + reportWorks = true; + await wipe.checkInDue(true); + await vi.waitFor(() => expect(wipeState.readWipeConfirmations()).toEqual([])); + const reports = sent.filter((call) => call.url.endsWith('/api/devices/wiped')); + expect(reports.length).toBeGreaterThanOrEqual(2); + expect(reports.at(-1)?.body).toMatchObject({ id: ROW, secret: SECRET }); + }); +}); + +describe('what never wipes', () => { + it.each([ + ['a 500', () => json(500, { error: 'Internal server error' })], + ['a 503 page', () => new Response('Bad gateway', { status: 503 })], + ['an older instance with no such route', () => new Response('Not found', { status: 404 })], + ['a 401', () => json(401, { error: 'Not authenticated' })], + ['a 200 that says nothing', () => json(200, {})], + ])('%s', async (_name, answer) => { + respond = answer; + await wipe.runDeviceGate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expectUntouched(); + }); + + it('no network, and the row keeps waiting at the instance: nothing was reported', async () => { + respond = () => { throw new TypeError('Failed to fetch'); }; + await wipe.runDeviceGate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expectUntouched(); + expect(sent.filter((call) => call.url.endsWith('/api/devices/wiped'))).toEqual([]); + }); + + it('a browser that says it\'s offline doesn\'t even ask', async () => { + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + await wipe.runDeviceGate(); + vi.restoreAllMocks(); + expect(sent).toEqual([]); + expectUntouched(); + }); + + it('a forgotten row: this device registers anew and keeps its data', async () => { + respond = (_url, init) => { + const { id } = JSON.parse(String(init?.body)) as { id?: string }; + return id ? json(404, { error: 'unknown_device' }) : json(200, { id: BOB_ROW, secret: SECRET }); + }; + await wipe.runDeviceGate(); + expectUntouched(); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + await wipe.checkInDue(true); + await vi.waitFor(async () => expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW)); + }); +}); + +describe('a start', () => { + it('waits for the answer, at most for its budget, and a late yes still wipes', async () => { + let release: (response: Response) => void = () => {}; + respond = (url) => { + if (url.endsWith('/api/devices/wiped')) return json(200, { ok: true }); + return new Promise((resolve) => { release = resolve; }); + }; + const started = Date.now(); + await wipe.runDeviceGate(); + const waited = Date.now() - started; + expect(waited).toBeGreaterThanOrEqual(wipe.BOOT_CHECK_IN_BUDGET_MS - 50); + expect(waited).toBeLessThan(wipe.BOOT_CHECK_IN_BUDGET_MS + 1500); + expectUntouched(); + + release(wipeNow()); + await waitForNoAccounts(); + expect(purge.files).toBeGreaterThan(0); + }); + + it('finishes a wipe a closed window cut short, with no network and before anything else', async () => { + wipeState.markWipePending(ALICE, 'admin'); + respond = () => { throw new TypeError('Failed to fetch'); }; + await wipe.runDeviceGate(); + expect(purge.files).toBeGreaterThan(0); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); + }); +}); + +describe('registering', () => { + beforeEach(async () => { + await keys.deleteDeviceRecord(ALICE); + }); + + it('a browser with offline on registers, once, naming its cookie slot', async () => { + respond = () => json(200, { id: ROW, secret: SECRET }); + await Promise.all([wipe.checkInDue(true), wipe.checkInDue(true)]); + expect(sent).toHaveLength(1); + expect(sent[0].headers['X-JMAP-Cookie-Slot']).toBe('0'); + expect(await keys.getDeviceRecord(ALICE)).toMatchObject({ id: ROW, secret: SECRET }); + }); + + it('a browser with offline off doesn\'t', async () => { + state.writeEnabled([]); + await wipe.checkInDue(true); + expect(sent).toEqual([]); + expect(await databases()).toContain('bulwark-store'); // From this test's own setup. + }); + + it('a desktop app on an instance does, with offline off too', async () => { + state.writeEnabled([]); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + respond = () => json(200, { id: ROW, secret: SECRET }); + await wipe.checkInDue(true); + expect(sent).toHaveLength(1); + }); + + it('the desktop app on its own has no instance to ask', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'standalone' }; + await wipe.checkInDue(true); + expect(sent).toEqual([]); + }); + + it('a cleared key database means a new row', async () => { + respond = () => json(200, { id: ROW, secret: SECRET }); + await wipe.checkInDue(true); + keys.deleteKeyDatabase(); + respond = () => json(200, { id: BOB_ROW, secret: SECRET }); + await wipe.checkInDue(true); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW); + }); + + it('a device id doesn\'t survive a wipe: the next sign-in is a new row', async () => { + await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 }); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(await keys.listDeviceRecords()).toEqual([]); + }); + + it('isn\'t tried again every minute when it can\'t', async () => { + respond = () => json(401, {}); + const now = Date.now(); + await wipe.checkInDue(false, now); + await wipe.checkInDue(false, now + 60_000); + expect(sent).toHaveLength(1); + await wipe.checkInDue(false, now + 16 * 60_000); + expect(sent).toHaveLength(2); + }); +}); + +describe('check-ins while running', () => { + it('come at most once an hour for a row', async () => { + const now = Date.now(); + await wipe.checkInDue(false, now); + await wipe.checkInDue(false, now + 60_000); + expect(sent).toHaveLength(1); + await wipe.checkInDue(false, now + 61 * 60_000); + expect(sent).toHaveLength(2); + }); +}); + +describe('a refused login (lib/offline/credentials-rejected.ts)', () => { + it('takes everything beyond the store too, and tells the instance why', async () => { + await openDb('smime-plugin-store'); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + wipe.registerDeviceWipe(); + rejected.noteCredentialsRejected(ALICE); + await vi.waitFor(() => expect(sent.some((call) => call.url.endsWith('/wiped'))).toBe(true)); + expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', id: ROW }); + await vi.waitFor(async () => expect(await databases()).not.toContain('smime-plugin-store')); + // The auth store is on its way with its own sign-out. + expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([ALICE]); + }); + + it('names the lost mail, as for any sign-out nobody asked for, and leaves no count note', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + const lost = await import('../lost'); + wipe.registerDeviceWipe(); + rejected.noteCredentialsRejected(ALICE); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + expect(lost.consumeLost()).toEqual(['Unsent']); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + + describe('from a running renewal', () => { + let heard: string[]; + + beforeEach(() => { + heard = []; + rejected.onCredentialsRejected((account) => heard.push(account)); + useAccountStore.setState({ accounts: [{ ...entry(ALICE, 0), authMode: 'oauth' }] } as never); + useAuthStore.setState({ authMode: 'oauth' }); + }); + + const token = (next: () => Response) => { respond = (url) => (url.includes('/api/auth/token') ? next() : noWipe()); }; + + it.each([ + ['a 500', () => json(500, { error: 'Internal server error' })], + ['a 503', () => json(503, { error: 'unavailable' })], + // What the route answers for a 400 invalid_client from the server. + ['a refusal other than invalid_grant', () => json(401, { error: 'Refresh failed' })], + ])('%s leaves the hook alone', async (_name, answer) => { + wipe.registerDeviceWipe(); + token(answer); + await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(heard).toEqual([]); + expect(purge.files).toBe(0); + expect(wipeState.readPendingWipes()).toEqual({}); + }); + + it.each([ + ['invalid_grant', 'invalid_grant'], + ['the session limit', 'session_expired'], + ])('%s fires it and wipes', async (_name, error) => { + wipe.registerDeviceWipe(); + token(() => json(401, { error, maxHours: 8 })); + await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); + expect(heard).toEqual([ALICE]); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + }); + }); + + it('a password refused for a day goes, unless it signed in again', async () => { + const heard: string[] = []; + rejected.onCredentialsRejected((account) => heard.push(account)); + state.markEvicted(ALICE); + state.writeEnabled([ALICE]); + const at = Date.now(); + wipeState.markLoginRefused(ALICE, at); + + await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS - 1000); + expect(heard).toEqual([]); + expect(purge.files).toBe(0); + + state.clearEvicted(ALICE); + await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS + 1000); + expect(heard).toEqual([]); + expect(purge.files).toBe(0); + expect(wipeState.readRefusedLogins()).toEqual({}); + + state.markEvicted(ALICE); + wipeState.markLoginRefused(ALICE, at); + wipe.registerDeviceWipe(); + await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS + 1000); + expect(heard).toEqual([ALICE]); + expect(purge.files).toBeGreaterThan(0); + expect(state.useOfflineStore.getState().enabled).toEqual([]); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); +}); + +describe('other windows', () => { + it('a window showing the wiped account reloads, one showing another stays, and the wiping window keeps going', async () => { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + const stop = wipe.startDeviceWipe(); + const other = new BroadcastChannel('bulwark-device-wipe'); + + other.postMessage({ account: BOB }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(reload).not.toHaveBeenCalled(); + + other.postMessage({ account: ALICE }); + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + + other.close(); + stop(); + vi.unstubAllGlobals(); + vi.stubGlobal('fetch', vi.fn(async () => noWipe())); + }); + + it('the window that wipes doesn\'t hear its own message', async () => { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + const stop = wipe.startDeviceWipe(); + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(reload).not.toHaveBeenCalled(); + stop(); + vi.unstubAllGlobals(); + vi.stubGlobal('fetch', vi.fn(async () => noWipe())); + }); +}); + +describe('what the desktop app keeps outside the page', () => { + const shell = { clearSurfaces: vi.fn(), clearClipboardFiles: vi.fn(async () => {}) }; + + beforeEach(() => { + shell.clearSurfaces.mockClear(); + shell.clearClipboardFiles.mockClear(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'instance', + os: { clearSurfaces: shell.clearSurfaces }, + clipboard: { clearFiles: shell.clearClipboardFiles }, + }; + }); + + it('goes with a remote wipe', async () => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(shell.clearSurfaces).toHaveBeenCalled(); + expect(shell.clearClipboardFiles).toHaveBeenCalled(); + }); + + it('goes with a wipe from settings', async () => { + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(shell.clearSurfaces).toHaveBeenCalled(); + expect(shell.clearClipboardFiles).toHaveBeenCalled(); + }); + + it('a shell without those calls wipes all the same', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(purge.files).toBeGreaterThan(0); + }); + + it('a call that fails leaves the wipe going', async () => { + shell.clearClipboardFiles.mockRejectedValueOnce(new Error('held by a program')); + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(purge.files).toBeGreaterThan(0); + }); +}); + +describe('the wipe from settings', () => { + it('takes everything, reports to the instance, and leaves no note: the user was there', async () => { + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(purge.files).toBeGreaterThan(0); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'user', id: ROW }); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + + it('marks that the user signed out, so a provider session doesn\'t sign them straight back in', async () => { + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(sessionStorage.getItem('signed_out')).toBe('true'); + }); +}); diff --git a/lib/offline/credentials-rejected.ts b/lib/offline/credentials-rejected.ts index a42282998..29c5c6015 100644 --- a/lib/offline/credentials-rejected.ts +++ b/lib/offline/credentials-rejected.ts @@ -1,9 +1,9 @@ /** * The server really refused this login (`invalid_grant`), which the auth store * follows with a sign-out that wipes the account's local data. An outage or any - * other refusal doesn't come here, and keeps the data. HOOK: lib/store/wipe - * registers here for what else a permanent rejection should do. Free of the - * stores, so the auth store can call it. + * other refusal doesn't come here, and keeps the data. lib/offline/device-wipe.ts + * registers here for everything beyond the local store. Free of the stores, so + * the auth store can call it. */ type Listener = (accountId: string) => void; diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts new file mode 100644 index 000000000..abf524982 --- /dev/null +++ b/lib/offline/device-wipe.ts @@ -0,0 +1,491 @@ +/** + * Everything that takes an account's data off this device, and how a device + * hears that it should: + * + * - the instance's registry (lib/devices): a wipe an admin asked for, heard at + * check-in. At a start the check-in runs before the local copy is shown. + * - the server refusing the stored login for good, through + * lib/offline/credentials-rejected.ts. + * - the user, from settings. + * + * Only a clear word wipes. An outage, a 500, an unknown answer: nothing happens. + * + * The desktop app's `WipeOfflineData` policy isn't here: its main process clears + * every partition itself (repos/desktop, src/main/features/local-wipe.ts), with no + * page taking part, so nothing a page runs could say it was done without doing it. + * + * Why a device id doesn't survive a wipe: the row keeps the history at the + * instance, and after a sign-in this is a device starting over, which the admin + * can wipe again. Keeping the id here would leave something of the account on a + * device that was told to remove it. What stays until the instance has heard is + * the row's id and secret, in lib/devices/wipe-state.ts. + * + * Accounts without cookies: a session cookie goes with the browser and the + * sealed one lasts 30 days, but a check-in only needs the secret, so a device + * still hears its wipe. One that lost its key database has nothing left to wipe + * and can't be told. Its row keeps saying the wipe is waiting. + */ + +import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; +import { sweepPrivateExports } from '@/lib/mail-export/sinks'; +import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; +import { fileStorage } from '@/lib/plugin-storage'; +import { clearHandoffs } from '@/lib/launch-handoff'; +import { apiFetch } from '@/lib/browser-navigation'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { + checkInWithInstance, + registerWithInstance, + reportWipeToInstance, +} from '@/lib/devices/client'; +import type { WipeReason } from '@/lib/devices/types'; +import { + addWipeNote, + clearWipePending, + dropRefusedLogin, + dropWipeConfirmation, + markWipePending, + queueWipeConfirmation, + readBlockedDatabases, + readPendingWipes, + readRefusedLogins, + readWipeConfirmations, + REFUSED_KEEP_MS, + WHOLE_DEVICE, + writeBlockedDatabases, +} from '@/lib/devices/wipe-state'; +import { + deleteDeviceRecord, + getDeviceRecord, + listDeviceRecords, + putDeviceRecord, + type DeviceRecord, +} from '@/lib/store/client/keys'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { noteCredentialsRejected, onCredentialsRejected } from './credentials-rejected'; +import { confirmLoss, registerOfflineHooks, unsentCount, wipeAccount, wipeEverything } from './offline-mode'; +import { clearEvicted, isEvicted, readEvicted, useOfflineStore } from './state'; +import { runWipeCleaners } from './wipe-registry'; + +/** How long a start holds the local copy back for the instance's answer. Offline, it doesn't wait at all. */ +export const BOOT_CHECK_IN_BUDGET_MS = 1_000; +/** A check-in of each device row, at most this often. */ +export const CHECK_IN_EVERY_MS = 60 * 60 * 1000; +/** How often a running page looks whether something is due. */ +const TICK_MS = 60 * 1000; +/** How long a wipe waits to tell the instance before it goes on: it tries again at the next contact. */ +const REPORT_BUDGET_MS = 3_000; +/** How long a wipe waits for one database a running plugin frame holds open. */ +const DATABASE_BUDGET_MS = 2_000; +const DATABASE_RETRIES = 5; + +const WIPE_CHANNEL = 'bulwark-device-wipe'; + +/** The webmail's own databases. Any other on this origin was made by a privileged plugin. */ +const APP_DATABASES = new Set([ + 'bulwark-store', + 'bulwark-plugins', + 'bulwark-company-calendars', + 'bulwark-mail-import', + 'bulwark-offline', +]); +/** Deleted by name too, for browsers without `indexedDB.databases()`. */ +const KNOWN_PLUGIN_DATABASES = ['smime-plugin-store', 'bulwark-openpgp', 'smime-capability-probe', 'openpgp-capability-probe']; + +/** What every account shared and can't be told apart by account. */ +const SHARED_LOCAL_KEYS = ['notification-center', 'plugin-oauth-states', 'webmail_usernames', 'global-search', 'favicon-refresh']; + +const running = new Map>(); + +// ---- Removing it ---- + +function deleteDatabase(name: string): Promise { + return new Promise((resolve) => { + let request: IDBOpenDBRequest; + const timer = setTimeout(() => resolve(false), DATABASE_BUDGET_MS); + const finish = (done: boolean) => { + clearTimeout(timer); + resolve(done); + }; + try { + request = indexedDB.deleteDatabase(name); + } catch { + finish(true); + return; + } + request.onsuccess = () => finish(true); + request.onerror = () => finish(false); + }); +} + +/** + * Plugin-owned databases. S/MIME and OpenPGP keys aren't kept per account, so a + * wipe of one account takes everyone's: keeping them would leave a wiped user's + * private key behind. They're encrypted and come back from the user's own backup. + */ +async function deletePluginDatabases(): Promise { + if (typeof indexedDB === 'undefined') return; + const names = new Set([...KNOWN_PLUGIN_DATABASES, ...Object.keys(readBlockedDatabases())]); + try { + for (const { name } of await indexedDB.databases()) { + if (name && !APP_DATABASES.has(name)) names.add(name); + } + } catch { + // No list in this browser: the names above. + } + const tries = readBlockedDatabases(); + const blocked: Record = {}; + await Promise.all([...names].map(async (name) => { + if (await deleteDatabase(name)) return; + const count = (tries[name] ?? 0) + 1; + if (count < DATABASE_RETRIES) blocked[name] = count; + })); + writeBlockedDatabases(blocked); +} + +/** + * What the desktop app keeps outside this page's storage: attachment copies, files + * on the clipboard, toasts, tray and jump list. Each is feature detected, since an + * older shell has none of them and a browser no bridge. + */ +async function clearShellFiles(): Promise { + const bridge = getDesktopBridge() as unknown as { + os?: { clearSurfaces?: () => void }; + clipboard?: { clearFiles?: () => void | Promise }; + } | null; + bridge?.os?.clearSurfaces?.(); + await bridge?.clipboard?.clearFiles?.(); +} + +/** A running frame is gone at the next load. Before any plugin starts again. */ +async function retryBlockedDatabases(): Promise { + if (Object.keys(readBlockedDatabases()).length > 0) await deletePluginDatabases(); +} + +/** The accounts' connected plugin accounts, which only sit on this device in the desktop app's own server. */ +async function forgetPluginAccounts(account: string | null): Promise { + if (!IS_DESKTOP_BUILD) return; + const accounts = useAccountStore.getState().accounts; + const slots = (account === null ? accounts : accounts.filter((entry) => entry.id === account)).map((entry) => entry.cookieSlot ?? 0); + await Promise.all(slots.map((slot) => apiFetch('/api/plugin-secrets?all=true', { + method: 'DELETE', + headers: { 'X-JMAP-Cookie-Slot': String(slot) }, + signal: AbortSignal.timeout(REPORT_BUDGET_MS), + }).catch(() => {}))); +} + +async function forgetPluginSettings(): Promise { + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState((state) => ({ plugins: state.plugins.map((plugin) => ({ ...plugin, settings: {} })) })); +} + +function removeLocalKeys(keys: string[]): void { + for (const key of keys) { + try { + localStorage.removeItem(key); + } catch { + /* storage unavailable */ + } + } +} + +/** Everything else that's the account's or that accounts share. `account` null: all of it. */ +async function scrub(account: string | null): Promise { + await runWipeCleaners(account); + await clearShellFiles().catch(() => {}); + await deletePluginDatabases(); + await forgetPluginAccounts(account); + await forgetPluginSettings().catch(() => {}); + if (account === null) clearAllPluginStorage(); + else clearPluginStorageForAccount(account); + await fileStorage.clearFiles().catch(() => {}); + await clearAllCheckpoints().catch(() => {}); + await sweepPrivateExports({ force: true }).catch(() => {}); + try { + indexedDB.deleteDatabase('bulwark-company-calendars'); + } catch { + /* storage unavailable */ + } + clearHandoffs(); + const { useNotificationCenterStore } = await import('@/stores/notification-center-store'); + useNotificationCenterStore.getState().clear(); + removeLocalKeys(SHARED_LOCAL_KEYS); +} + +async function flushConfirmations(): Promise { + await Promise.all(readWipeConfirmations().map(async (entry) => { + if (await reportWipeToInstance(entry, AbortSignal.timeout(REPORT_BUDGET_MS))) dropWipeConfirmation(entry.id); + })); +} + +function signalSignedOut(): void { + // The login page would otherwise sign straight back in through a provider session. + try { + sessionStorage.setItem('signed_out', 'true'); + } catch { + /* noop */ + } +} + +/** One object for sending and hearing: a channel doesn't hear itself, so this window doesn't reload. */ +let wipeChannel: BroadcastChannel | null = null; + +function otherWindows(): BroadcastChannel | null { + try { + wipeChannel ??= new BroadcastChannel(WIPE_CHANNEL); + return wipeChannel; + } catch { + return null; + } +} + +function tellOtherWindows(account: string | null): void { + otherWindows()?.postMessage({ account }); +} + +async function signOutAccount(account: string): Promise { + const auth = useAuthStore.getState(); + signalSignedOut(); + if (auth.activeAccountId === account) await auth.logout({ endProviderSession: false }); + else auth.removeAccount(account, 'explicit'); +} + +/** + * Takes one account off this device and signs it out. A refused login's sign-out + * is the auth store's own, already on its way, and its lost mail is named as for + * any sign-out nobody asked for. Everywhere else lost mail is a count. + */ +export function wipeAccountFromDevice(account: string, reason: WipeReason): Promise { + const under = running.get(account); + if (under) return under; + const work = eraseAccount(account, reason).finally(() => running.delete(account)); + running.set(account, work); + return work; +} + +async function eraseAccount(account: string, reason: WipeReason): Promise { + markWipePending(account, reason); + const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + const record = records.find((entry) => entry.account === account); + const named = reason === 'rejected'; + const unsent = named ? 0 : (await unsentCount(account).catch(() => ({ messages: 0 }))).messages; + registerOfflineHooks(); + if (!named) confirmLoss(account); + await wipeAccount(account).catch(() => {}); + clearEvicted(account); + dropRefusedLogin(account); + await scrub(account).catch(() => {}); + + // The last account's wipe deletes the whole key database, with the rows of accounts that stay. + await deleteDeviceRecord(account).catch(() => {}); + for (const other of records) { + if (other.account !== account) await putDeviceRecord(other).catch(() => {}); + } + + if (record) queueWipeConfirmation({ id: record.id, secret: record.secret, reason, unsent }); + if (reason === 'admin') addWipeNote(unsent); + await flushConfirmations(); + if (!named) await signOutAccount(account); + clearWipePending(account); + tellOtherWindows(account); +} + +/** Everything on this device, for everyone. The app's device preferences stay: they hold no mail. */ +export function wipeWholeDevice(reason: WipeReason): Promise { + const under = running.get(WHOLE_DEVICE); + if (under) return under; + const work = eraseDevice(reason).finally(() => running.delete(WHOLE_DEVICE)); + running.set(WHOLE_DEVICE, work); + return work; +} + +async function eraseDevice(reason: WipeReason): Promise { + markWipePending(WHOLE_DEVICE, reason); + const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + const unsent = (await unsentCount('all').catch(() => ({ messages: 0 }))).messages; + registerOfflineHooks(); + confirmLoss('all'); + await scrub(null).catch(() => {}); + await wipeEverything().catch(() => {}); + for (const account of Object.keys(readEvicted())) clearEvicted(account); + for (const account of Object.keys(readRefusedLogins())) dropRefusedLogin(account); + for (const record of records) queueWipeConfirmation({ id: record.id, secret: record.secret, reason, unsent }); + if (reason === 'admin') addWipeNote(unsent); + await flushConfirmations(); + signalSignedOut(); + await useAuthStore.getState().logoutAll({ endProviderSession: false }); + clearWipePending(WHOLE_DEVICE); + tellOtherWindows(null); +} + +// ---- Hearing about it ---- + +/** Whether this device has an instance to talk to. The desktop app's own server has no admin to ask. */ +function hasInstance(): boolean { + if (IS_DESKTOP_BUILD) return false; + return getDesktopBridge()?.mode !== 'standalone'; +} + +function wantsRow(account: { id: string; awaitingSignIn?: boolean; impersonated?: boolean }): boolean { + if (account.awaitingSignIn || account.impersonated) return false; + return getDesktopBridge()?.mode === 'instance' || useOfflineStore.getState().enabled.includes(account.id); +} + +const lastCheckIn = new Map(); +const lastRegisterTry = new Map(); +/** An older instance, or a session cookie that isn't back yet: not a request a minute. */ +const REGISTER_RETRY_MS = 15 * 60 * 1000; +let checkInFlight: Promise | null = null; + +async function checkInRecord(record: DeviceRecord, now: number): Promise { + lastCheckIn.set(record.account, now); + const answer = await checkInWithInstance(record, AbortSignal.timeout(REPORT_BUDGET_MS * 3)); + if (answer === 'wipe') { + await wipeAccountFromDevice(record.account, 'admin'); + return; + } + if (answer === 'forgotten') { + await deleteDeviceRecord(record.account).catch(() => {}); + return; + } + // Signed out here with nothing kept: the row has nothing left to look after. + if (answer === 'clear') { + const known = useAccountStore.getState().accounts.some((entry) => entry.id === record.account); + if (!known && !useOfflineStore.getState().enabled.includes(record.account)) await deleteDeviceRecord(record.account).catch(() => {}); + } +} + +async function registerAccount(accountId: string, slot: number): Promise { + const register = async () => { + // Another window may have got here while this one waited. + if (await getDeviceRecord(accountId)) return; + const row = await registerWithInstance(slot, AbortSignal.timeout(REPORT_BUDGET_MS * 3)); + if (row) await putDeviceRecord({ account: accountId, id: row.id, secret: row.secret, createdAt: Date.now() }); + }; + if (typeof navigator !== 'undefined' && navigator.locks) await navigator.locks.request('bulwark-device-register', register); + else await register(); +} + +/** Each row that's due, and a row for each account that needs one. `force`: every row, now. One at a time. */ +export function checkInDue(force = false, now = Date.now()): Promise { + if (checkInFlight) return checkInFlight; + checkInFlight = runCheckIns(force, now).finally(() => { checkInFlight = null; }); + return checkInFlight; +} + +async function runCheckIns(force: boolean, now: number): Promise { + if (!hasInstance()) return; + if (typeof navigator !== 'undefined' && navigator.onLine === false) return; + void flushConfirmations(); + const accounts = useAccountStore.getState().accounts; + const wanted = accounts.filter(wantsRow); + // No account with a reason and no database to look in: don't make one by looking. + const records = await listDeviceRecords(wanted.length === 0).catch(() => [] as DeviceRecord[]); + await Promise.all([ + ...records + .filter((record) => force || now - (lastCheckIn.get(record.account) ?? 0) >= CHECK_IN_EVERY_MS) + .map((record) => checkInRecord(record, now)), + ...wanted + .filter((account) => !records.some((record) => record.account === account.id)) + .filter((account) => force || now - (lastRegisterTry.get(account.id) ?? 0) >= REGISTER_RETRY_MS) + .map((account) => { + lastRegisterTry.set(account.id, now); + return registerAccount(account.id, account.cookieSlot ?? 0).catch(() => {}); + }), + ]); +} + +/** A wipe cut short by a crash or a closed window: finished before anything else. No network. */ +export async function finishInterruptedWipes(): Promise { + const pending = readPendingWipes(); + for (const [account, reason] of Object.entries(pending)) { + if (account === WHOLE_DEVICE) await wipeWholeDevice(reason); + else await wipeAccountFromDevice(account, reason); + } + await retryBlockedDatabases(); +} + +/** Refused passwords whose day has passed, with no sign-in since. */ +export async function sweepRefusedLogins(now = Date.now()): Promise { + for (const [account, at] of Object.entries(readRefusedLogins())) { + // Signed in again: the wait is over and nothing goes. + if (!isEvicted(account)) { + dropRefusedLogin(account); + continue; + } + if (now - at < REFUSED_KEEP_MS) continue; + dropRefusedLogin(account); + noteCredentialsRejected(account); + await running.get(account); + } +} + +let registered = false; + +/** The hooks every wipe needs, once per page. */ +export function registerDeviceWipe(): void { + if (registered) return; + registered = true; + onCredentialsRejected((account) => { + void wipeAccountFromDevice(account, 'rejected'); + }); +} + +/** + * What a start waits for, before any local data is shown: a wipe that was cut + * short, without limit, and the instance's answer, up to the budget. An answer + * that comes later still wipes, from wherever the page has got to. + */ +export async function runDeviceGate(): Promise { + registerDeviceWipe(); + await finishInterruptedWipes(); + await sweepRefusedLogins(); + const checks = checkInDue(true); + await Promise.race([ + checks.catch(() => {}), + new Promise((resolve) => setTimeout(resolve, BOOT_CHECK_IN_BUDGET_MS)), + ]); +} + +function listenToOtherWindows(): () => void { + const channel = otherWindows(); + if (!channel) return () => {}; + channel.onmessage = (event: MessageEvent<{ account: string | null }>) => { + const { account } = event.data ?? {}; + // Its mail is in this window's memory. Another account's unsaved text isn't touched. + if (account === null || account === useAuthStore.getState().activeAccountId) window.location.reload(); + }; + return () => { channel.onmessage = null; }; +} + +/** For the host component: all of it, for as long as the page runs. */ +export function startDeviceWipe(): () => void { + registerDeviceWipe(); + if (isPopoutWindow()) return listenToOtherWindows(); + const stopOthers = listenToOtherWindows(); + void (async () => { + await finishInterruptedWipes(); + await sweepRefusedLogins(); + await checkInDue(); + })().catch(() => {}); + const tick = setInterval(() => { + void checkInDue().catch(() => {}); + void sweepRefusedLogins().catch(() => {}); + }, TICK_MS); + const onOnline = () => { void checkInDue(true).catch(() => {}); }; + window.addEventListener('online', onOnline); + const stopEnabled = useOfflineStore.subscribe((state, prev) => { + if (state.enabled === prev.enabled) return; + for (const account of state.enabled) lastRegisterTry.delete(account); + void checkInDue().catch(() => {}); + }); + return () => { + stopOthers(); + stopEnabled(); + clearInterval(tick); + window.removeEventListener('online', onOnline); + }; +} diff --git a/lib/offline/wipe-registry.ts b/lib/offline/wipe-registry.ts new file mode 100644 index 000000000..2b9d8f393 --- /dev/null +++ b/lib/offline/wipe-registry.ts @@ -0,0 +1,39 @@ +/** + * Where a feature with data of its own on this device signs up for wipes: + * edit-in-place's opened files, for one. Free of the stores, so anything can + * import it. Registering is all a feature has to do. + * + * A cleaner runs on every remote wipe, policy wipe, wipe from settings and + * refused login, before the account signs out. `account` is an account id, or + * `null` when everything on this device goes. A cleaner should finish quickly + * and may not ask the user anything. Bridge calls are fine, network isn't: a + * wipe often runs because the server said no. + */ + +export type WipeCleaner = (account: string | null) => void | Promise; + +const cleaners = new Set(); + +export function onDeviceWipe(cleaner: WipeCleaner): () => void { + cleaners.add(cleaner); + return () => cleaners.delete(cleaner); +} + +/** How long a wipe waits for one cleaner. What's on screen waits with it. */ +const CLEANER_BUDGET_MS = 10_000; + +export async function runWipeCleaners(account: string | null): Promise { + await Promise.all([...cleaners].map(async (cleaner) => { + let timer: ReturnType | undefined; + try { + await Promise.race([ + cleaner(account), + new Promise((resolve) => { timer = setTimeout(resolve, CLEANER_BUDGET_MS); }), + ]); + } catch { + // One feature failing must not keep the others' data. + } finally { + if (timer) clearTimeout(timer); + } + })); +} diff --git a/lib/plugin-secrets/vault.ts b/lib/plugin-secrets/vault.ts index ce1e4d8de..c7c74329c 100644 --- a/lib/plugin-secrets/vault.ts +++ b/lib/plugin-secrets/vault.ts @@ -217,6 +217,11 @@ export function setUserCredentialSecret( }); } +/** Everything this user connected, for every plugin. */ +export async function deleteAllUserCredentials(user: VaultUser): Promise { + await collection().delete(userKey(user)); +} + /** Remove one credential, or all of a plugin's when credentialId is omitted. */ export function deleteUserCredentials(user: VaultUser, pluginId: string, credentialId?: string): Promise { return modify(user, (data) => { diff --git a/lib/shared-store/__tests__/import-data.test.ts b/lib/shared-store/__tests__/import-data.test.ts index 80e1c589c..9335c4b9d 100644 --- a/lib/shared-store/__tests__/import-data.test.ts +++ b/lib/shared-store/__tests__/import-data.test.ts @@ -14,6 +14,7 @@ import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault'; import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; +import { DEVICES_COLLECTION } from '@/lib/devices/registry'; // An existing single instance's settings, plugin credentials, archives, // telemetry and update-check state go into an empty shared store once, as @@ -43,9 +44,12 @@ beforeEach(() => { vi.stubEnv('PLUGIN_SECRETS_DIR', path.join(data, 'plugin-secrets')); vi.stubEnv('TELEMETRY_DATA_DIR', path.join(data, 'telemetry')); vi.stubEnv('VERSION_CHECK_DATA_DIR', path.join(data, 'version-check')); + vi.stubEnv('ADMIN_STATE_DIR', path.join(data, 'admin-state')); for (const dir of ['settings/account-archives', 'plugin-secrets', 'telemetry', 'version-check']) { mkdirSync(path.join(data, dir), { recursive: true }); } + mkdirSync(path.join(data, 'admin-state', 'devices', hash('f')), { recursive: true }); + writeFileSync(path.join(data, 'admin-state', 'devices', hash('f'), `${'0'.repeat(32)}.json`), '{"user":"a"}'); writeFileSync(path.join(data, 'settings', `${hash('a')}.enc`), Buffer.from([1, 2, 3, 4])); writeFileSync(path.join(data, 'settings', `${hash('b')}.enc`), Buffer.from([5, 6])); writeFileSync(path.join(data, 'settings', `${hash('c')}.enc.tmp`), 'half written'); @@ -78,10 +82,10 @@ describe('importing part data', () => { const before = snapshot(data); expect(await runAll()).toEqual({ - settings: 2, 'plugin-secrets': 1, 'account-archives': 1, telemetry: 3, 'version-check': 1, + settings: 2, 'plugin-secrets': 1, 'account-archives': 1, telemetry: 3, 'version-check': 1, devices: 1, }); expect(await runAll()).toEqual({ - settings: 0, 'plugin-secrets': 0, 'account-archives': 0, telemetry: 0, 'version-check': 0, + settings: 0, 'plugin-secrets': 0, 'account-archives': 0, telemetry: 0, 'version-check': 0, devices: 0, }); expect(snapshot(data)).toEqual(before); @@ -92,6 +96,7 @@ describe('importing part data', () => { expect((await store.collection('account-archives', ARCHIVE_COLLECTION).get(hash('d')))?.data.toString()).toBe('{"version":1}'); expect((await store.collection('telemetry', TELEMETRY_COLLECTION).list()).sort()).toEqual(['.telemetry-id', 'logins.json', 'state.json']); expect((await store.collection('version-check', VERSION_CHECK_COLLECTION).get('state.json'))?.data.toString()).toBe('{"status":null}'); + expect((await store.collection('devices', DEVICES_COLLECTION).get(`${hash('f')}/${'0'.repeat(32)}`))?.data.toString()).toBe('{"user":"a"}'); }); it('keeps what the store already holds', async () => { diff --git a/lib/shared-store/export.ts b/lib/shared-store/export.ts index d14775c0f..9a7f2297f 100644 --- a/lib/shared-store/export.ts +++ b/lib/shared-store/export.ts @@ -11,6 +11,7 @@ import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault'; import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; +import { DEVICES_COLLECTION } from '@/lib/devices/registry'; import { getStore } from './index'; import { exportSteps as extExportSteps } from './import/ext'; import { localCollection, localLog } from './local'; @@ -93,6 +94,7 @@ export const EXPORT_STEPS: ExportStep[] = [ exportCollection('account-archives', ARCHIVE_COLLECTION), exportCollection('telemetry', TELEMETRY_COLLECTION), exportCollection('version-check', VERSION_CHECK_COLLECTION), + exportCollection('devices', DEVICES_COLLECTION), ]; export async function exportSharedData(steps: ExportStep[] = EXPORT_STEPS): Promise> { diff --git a/lib/shared-store/import/data.ts b/lib/shared-store/import/data.ts index 9af9a1030..592cfe671 100644 --- a/lib/shared-store/import/data.ts +++ b/lib/shared-store/import/data.ts @@ -7,6 +7,7 @@ import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault'; import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; +import { DEVICES_COLLECTION } from '@/lib/devices/registry'; /** * Part `data` of the shared-store import (#1066): every document of these @@ -39,4 +40,5 @@ export const steps: ImportStep[] = [ // state.json, logins.json and the instance id .telemetry-id copyCollection('telemetry', TELEMETRY_COLLECTION), copyCollection('version-check', VERSION_CHECK_COLLECTION), + copyCollection('devices', DEVICES_COLLECTION), ]; diff --git a/lib/store/client/keys.ts b/lib/store/client/keys.ts index 9723c6710..3e9ea856c 100644 --- a/lib/store/client/keys.ts +++ b/lib/store/client/keys.ts @@ -164,6 +164,55 @@ export async function forgetAccountKey(account: string): Promise { await withDb((db) => promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).delete(account)).then(() => undefined)); } +/** + * This browser profile's row at the instance, for one account: its id, and the + * secret that lets a device answer for that row without a session. In this + * database and nowhere else, because a start has to read it before any store + * opens, and a copy in the store could never outlive the key that opens it. A + * cleared database means a new device at the next registration. + */ +export interface DeviceRecord { + account: string; + id: string; + secret: string; + createdAt: number; +} + +const DEVICE_RECORD_PREFIX = 'device:'; +// ':' + 1, so the range holds exactly the prefixed keys. +const DEVICE_RECORD_END = 'device;'; + +export async function getDeviceRecord(account: string): Promise { + return withDb(async (db) => { + const found = await promisify(db.transaction(KEYS, 'readonly').objectStore(KEYS).get(DEVICE_RECORD_PREFIX + account)) as { record: DeviceRecord } | undefined; + return found?.record ?? null; + }); +} + +export async function putDeviceRecord(record: DeviceRecord): Promise { + await withDb((db) => promisify(db.transaction(KEYS, 'readwrite').objectStore(KEYS).put({ id: DEVICE_RECORD_PREFIX + record.account, record })).then(() => undefined)); +} + +export async function deleteDeviceRecord(account: string): Promise { + if (!(await keyDatabaseExists())) return; + await withDb((db) => promisify(db.transaction(KEYS, 'readwrite').objectStore(KEYS).delete(DEVICE_RECORD_PREFIX + account)).then(() => undefined)); +} + +/** Opening the database makes it, so a browser that can say it isn't there doesn't open it. */ +async function keyDatabaseExists(): Promise { + if (typeof indexedDB.databases !== 'function') return true; + return (await indexedDB.databases()).some((database) => database.name === DB_NAME); +} + +export async function listDeviceRecords(onlyIfThere = true): Promise { + if (onlyIfThere && !(await keyDatabaseExists())) return []; + return withDb(async (db) => { + const range = IDBKeyRange.bound(DEVICE_RECORD_PREFIX, DEVICE_RECORD_END, false, true); + const rows = await promisify(db.transaction(KEYS, 'readonly').objectStore(KEYS).getAll(range)) as Array<{ record: DeviceRecord }>; + return rows.map((row) => row.record); + }); +} + export class StoreKeyLayerMissingError extends Error { constructor(readonly layer: string) { super(`The key needs the ${layer} layer`); diff --git a/locales/ar/common.json b/locales/ar/common.json index 654025307..9910df082 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5763,6 +5763,23 @@ "store_private": "لا يمكن للنوافذ الخاصة الاحتفاظ بالبريد للاستخدام دون اتصال.", "server_not_answering": "خادم البريد لا يستجيب. حاول مرة أخرى بعد قليل." }, + "offlineWipe": { + "note": "أزالت مؤسستك بيانات هذا الحساب من هذا الجهاز. سجّل الدخول للبدء من جديد.", + "note_unsent": "أزالت مؤسستك بيانات هذا الحساب من هذا الجهاز. الرسائل غير المرسلة المحذوفة: {count}.", + "settings_title": "إزالة كل شيء من هذا الجهاز", + "settings_description": "عندما تعطي هذا الحاسوب لشخص آخر أو تتوقف عن استخدامه. يبقى بريدك على الخادم.", + "settings_button": "إزالة كل شيء…", + "settings_wiping": "جارٍ الإزالة…", + "confirm_title": "هل تريد إزالة كل شيء من هذا الجهاز؟", + "confirm_message": "سيحذف هذا ما يحتفظ به التطبيق على هذا الجهاز، لكل الحسابات هنا. لن يتأثر بريدك على الخادم.", + "confirm_mail": "البريد والتقاويم وجهات الاتصال والملفات المحفوظة هنا", + "confirm_unsent": "الرسائل غير المرسلة المنتظرة هنا: {count}. ستُفقد.", + "confirm_keys": "مفاتيح S/MIME وOpenPGP. ستحتاج إلى نسختك الاحتياطية لاستخدامها مجددًا.", + "confirm_plugins": "البيانات التي خزّنتها الإضافات هنا", + "confirm_files": "المرفقات المفتوحة للتحرير والملفات المنتظرة للإرفاق", + "confirm_accounts": "يتم تسجيل الخروج من كل الحسابات", + "confirm_button": "إزالة كل شيء" + }, "offlineDrafts": { "title": "المسودات على هذا الجهاز", "description": "هذه المسودات محفوظة على هذا الجهاز. ستنتقل إلى الخادم تلقائيًا فور إمكان الوصول إليه.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0a9eb6fb2..db7de5235 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5763,6 +5763,23 @@ "store_private": "Les finestres privades no poden desar correu per fer-lo servir sense connexió.", "server_not_answering": "El servidor de correu no respon. Torneu-ho a provar d'aquí a un moment." }, + "offlineWipe": { + "note": "La teva organització ha tret les dades d'aquest compte d'aquest dispositiu. Inicia la sessió per tornar a començar.", + "note_unsent": "La teva organització ha tret les dades d'aquest compte d'aquest dispositiu. Missatges sense enviar eliminats: {count}.", + "settings_title": "Treu-ho tot d'aquest dispositiu", + "settings_description": "Per quan regalis aquest ordinador o el deixis d'utilitzar. El teu correu al servidor es manté.", + "settings_button": "Treu-ho tot…", + "settings_wiping": "S'està traient…", + "confirm_title": "Vols treure-ho tot d'aquest dispositiu?", + "confirm_message": "Això elimina el que aquesta aplicació guarda en aquest dispositiu, per a tots els comptes d'aquí. El teu correu al servidor no es toca.", + "confirm_mail": "Correu, calendaris, contactes i fitxers guardats aquí", + "confirm_unsent": "Missatges sense enviar en espera aquí: {count}. Es perdran.", + "confirm_keys": "Claus S/MIME i OpenPGP. Necessitaràs la teva pròpia còpia de seguretat per tornar-les a utilitzar.", + "confirm_plugins": "Dades que els connectors han guardat aquí", + "confirm_files": "Adjunts oberts per editar i fitxers pendents d'adjuntar", + "confirm_accounts": "Es tanca la sessió de tots els comptes", + "confirm_button": "Treu-ho tot" + }, "offlineDrafts": { "title": "Esborranys en aquest dispositiu", "description": "Aquests esborranys estan desats en aquest dispositiu. Aniran al servidor sols tan bon punt s'hi pugui connectar.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 18f0c03ee..82b65a32e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5763,6 +5763,23 @@ "store_private": "Soukromá okna nemohou uchovávat poštu pro použití offline.", "server_not_answering": "Poštovní server neodpovídá. Zkuste to za chvíli znovu." }, + "offlineWipe": { + "note": "Vaše organizace odebrala data tohoto účtu z tohoto zařízení. Přihlaste se a začněte znovu.", + "note_unsent": "Vaše organizace odebrala data tohoto účtu z tohoto zařízení. Smazané neodeslané zprávy: {count}.", + "settings_title": "Odebrat vše z tohoto zařízení", + "settings_description": "Pro případ, že tento počítač předáte dál nebo ho přestanete používat. Vaše pošta na serveru zůstane.", + "settings_button": "Odebrat vše…", + "settings_wiping": "Odebírání…", + "confirm_title": "Odebrat vše z tohoto zařízení?", + "confirm_message": "Tím se smaže to, co tato aplikace uchovává na tomto zařízení, pro všechny zdejší účty. Vaší pošty na serveru se to nedotkne.", + "confirm_mail": "Pošta, kalendáře, kontakty a soubory uložené zde", + "confirm_unsent": "Neodeslané zprávy čekající zde: {count}. Budou ztraceny.", + "confirm_keys": "Klíče S/MIME a OpenPGP. Abyste je mohli znovu použít, budete potřebovat vlastní zálohu.", + "confirm_plugins": "Data, která sem uložily zásuvné moduly", + "confirm_files": "Přílohy otevřené k úpravám a soubory čekající na připojení", + "confirm_accounts": "Všechny účty budou odhlášeny", + "confirm_button": "Odebrat vše" + }, "offlineDrafts": { "title": "Koncepty v tomto zařízení", "description": "Tyto koncepty jsou uložené v tomto zařízení. Na server se odešlou samy, jakmile bude dostupný.", diff --git a/locales/da/common.json b/locales/da/common.json index 5578eeca0..96e55884f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5763,6 +5763,23 @@ "store_private": "Private vinduer kan ikke gemme mail til brug offline.", "server_not_answering": "Mailserveren svarer ikke. Prøv igen om lidt." }, + "offlineWipe": { + "note": "Din organisation har fjernet denne kontos data fra denne enhed. Log ind for at starte forfra.", + "note_unsent": "Din organisation har fjernet denne kontos data fra denne enhed. Slettede ikke-sendte beskeder: {count}.", + "settings_title": "Fjern alt fra denne enhed", + "settings_description": "Til når du giver computeren væk eller holder op med at bruge den. Din post på serveren bliver.", + "settings_button": "Fjern alt…", + "settings_wiping": "Fjerner…", + "confirm_title": "Fjern alt fra denne enhed?", + "confirm_message": "Det sletter det, denne app gemmer på denne enhed, for alle konti her. Din post på serveren røres ikke.", + "confirm_mail": "Post, kalendere, kontakter og filer gemt her", + "confirm_unsent": "Ikke-sendte beskeder, der venter her: {count}. De går tabt.", + "confirm_keys": "S/MIME- og OpenPGP-nøgler. Du skal bruge din egen sikkerhedskopi for at bruge dem igen.", + "confirm_plugins": "Data, som plugins har gemt her", + "confirm_files": "Vedhæftede filer åbnet til redigering og filer, der venter på at blive vedhæftet", + "confirm_accounts": "Alle konti logges ud", + "confirm_button": "Fjern alt" + }, "offlineDrafts": { "title": "Kladder på denne enhed", "description": "Disse kladder er gemt på denne enhed. De sendes selv til serveren, så snart den kan nås.", diff --git a/locales/de/common.json b/locales/de/common.json index efb641f2b..5c74f7785 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5763,6 +5763,23 @@ "store_private": "Private Fenster können keine E-Mails für die Offline-Nutzung behalten.", "server_not_answering": "Der Mailserver antwortet nicht. Versuchen Sie es gleich noch einmal." }, + "offlineWipe": { + "note": "Ihre Organisation hat die Daten dieses Kontos von diesem Gerät entfernt. Melden Sie sich an, um neu zu beginnen.", + "note_unsent": "Ihre Organisation hat die Daten dieses Kontos von diesem Gerät entfernt. Gelöschte ungesendete Nachrichten: {count}.", + "settings_title": "Alles von diesem Gerät entfernen", + "settings_description": "Für den Fall, dass Sie diesen Computer weitergeben oder nicht mehr nutzen. Ihre E-Mails auf dem Server bleiben erhalten.", + "settings_button": "Alles entfernen…", + "settings_wiping": "Wird entfernt…", + "confirm_title": "Alles von diesem Gerät entfernen?", + "confirm_message": "Damit wird gelöscht, was diese App auf diesem Gerät speichert, für alle Konten hier. Ihre E-Mails auf dem Server bleiben unberührt.", + "confirm_mail": "Hier gespeicherte E-Mails, Kalender, Kontakte und Dateien", + "confirm_unsent": "Hier wartende ungesendete Nachrichten: {count}. Sie gehen verloren.", + "confirm_keys": "S/MIME- und OpenPGP-Schlüssel. Um sie wieder zu nutzen, brauchen Sie Ihre eigene Sicherung.", + "confirm_plugins": "Daten, die Plugins hier gespeichert haben", + "confirm_files": "Zum Bearbeiten geöffnete Anhänge und Dateien, die noch angehängt werden sollen", + "confirm_accounts": "Alle Konten werden abgemeldet", + "confirm_button": "Alles entfernen" + }, "offlineDrafts": { "title": "Entwürfe auf diesem Gerät", "description": "Diese Entwürfe sind auf diesem Gerät gespeichert. Sie gehen von selbst an den Server, sobald er erreichbar ist.", diff --git a/locales/en/common.json b/locales/en/common.json index 7728594e1..5656dcf16 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5763,6 +5763,23 @@ "store_private": "Private windows can't keep mail for offline use.", "server_not_answering": "The mail server isn't answering. Try again in a moment." }, + "offlineWipe": { + "note": "Your organisation removed this account's data from this device. Sign in to start again.", + "note_unsent": "Your organisation removed this account's data from this device. Unsent messages deleted: {count}.", + "settings_title": "Remove everything from this device", + "settings_description": "For when you give this computer away or stop using it. Your mail on the server stays.", + "settings_button": "Remove everything…", + "settings_wiping": "Removing…", + "confirm_title": "Remove everything from this device?", + "confirm_message": "This deletes what this app keeps on this device, for every account here. Your mail on the server isn't touched.", + "confirm_mail": "Mail, calendars, contacts and files kept here", + "confirm_unsent": "Unsent messages waiting here: {count}. They will be lost.", + "confirm_keys": "S/MIME and OpenPGP keys. You need your own backup to use them again.", + "confirm_plugins": "Data that plugins stored here", + "confirm_files": "Attachments opened for editing and files waiting to be attached", + "confirm_accounts": "All accounts are signed out", + "confirm_button": "Remove everything" + }, "offlineDrafts": { "title": "Drafts on this device", "description": "These drafts are saved on this device. They go to the server by themselves once it can be reached.", diff --git a/locales/es/common.json b/locales/es/common.json index 5a2af15a4..20454525e 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5763,6 +5763,23 @@ "store_private": "Las ventanas privadas no pueden guardar correo para usarlo sin conexión.", "server_not_answering": "El servidor de correo no responde. Inténtalo de nuevo en un momento." }, + "offlineWipe": { + "note": "Tu organización ha quitado los datos de esta cuenta de este dispositivo. Inicia sesión para empezar de nuevo.", + "note_unsent": "Tu organización ha quitado los datos de esta cuenta de este dispositivo. Mensajes sin enviar eliminados: {count}.", + "settings_title": "Quitar todo de este dispositivo", + "settings_description": "Para cuando regales este ordenador o dejes de usarlo. Tu correo en el servidor se mantiene.", + "settings_button": "Quitar todo…", + "settings_wiping": "Quitando…", + "confirm_title": "¿Quitar todo de este dispositivo?", + "confirm_message": "Esto elimina lo que esta aplicación guarda en este dispositivo, para todas las cuentas de aquí. Tu correo en el servidor no se toca.", + "confirm_mail": "Correo, calendarios, contactos y archivos guardados aquí", + "confirm_unsent": "Mensajes sin enviar en espera aquí: {count}. Se perderán.", + "confirm_keys": "Claves S/MIME y OpenPGP. Necesitarás tu propia copia de seguridad para volver a usarlas.", + "confirm_plugins": "Datos que los plugins guardaron aquí", + "confirm_files": "Adjuntos abiertos para editar y archivos a la espera de adjuntarse", + "confirm_accounts": "Se cierra la sesión de todas las cuentas", + "confirm_button": "Quitar todo" + }, "offlineDrafts": { "title": "Borradores en este dispositivo", "description": "Estos borradores están guardados en este dispositivo. Irán al servidor por sí solos en cuanto se pueda conectar con él.", diff --git a/locales/et/common.json b/locales/et/common.json index b5ce8e291..7568cd70d 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5763,6 +5763,23 @@ "store_private": "Privaatsed aknad ei saa kirju võrguühenduseta kasutamiseks hoida.", "server_not_answering": "Meiliserver ei vasta. Proovige kohe uuesti." }, + "offlineWipe": { + "note": "Teie organisatsioon eemaldas selle konto andmed sellest seadmest. Alustamiseks logige uuesti sisse.", + "note_unsent": "Teie organisatsioon eemaldas selle konto andmed sellest seadmest. Kustutatud saatmata kirju: {count}.", + "settings_title": "Eemalda sellest seadmest kõik", + "settings_description": "Juhuks, kui annate selle arvuti ära või lõpetate selle kasutamise. Teie kirjad serveris jäävad alles.", + "settings_button": "Eemalda kõik…", + "settings_wiping": "Eemaldamine…", + "confirm_title": "Kas eemaldada sellest seadmest kõik?", + "confirm_message": "See kustutab kõik, mida see rakendus selles seadmes hoiab, kõigi siinsete kontode jaoks. Teie kirju serveris see ei puuduta.", + "confirm_mail": "Siin hoitavad kirjad, kalendrid, kontaktid ja failid", + "confirm_unsent": "Siin ootavaid saatmata kirju: {count}. Need lähevad kaotsi.", + "confirm_keys": "S/MIME- ja OpenPGP-võtmed. Nende uuesti kasutamiseks on vaja teie enda varukoopiat.", + "confirm_plugins": "Pistikprogrammide siia salvestatud andmed", + "confirm_files": "Muutmiseks avatud manused ja lisamist ootavad failid", + "confirm_accounts": "Kõik kontod logitakse välja", + "confirm_button": "Eemalda kõik" + }, "offlineDrafts": { "title": "Mustandid selles seadmes", "description": "Need mustandid on salvestatud selles seadmes. Need lähevad serverisse ise, kui server on kättesaadav.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 22a2c1d43..25c3a7062 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5763,6 +5763,23 @@ "store_private": "پنجره‌های خصوصی نمی‌توانند ایمیل را برای استفادهٔ آفلاین نگه دارند.", "server_not_answering": "سرور ایمیل پاسخ نمی‌دهد. کمی بعد دوباره امتحان کنید." }, + "offlineWipe": { + "note": "سازمان شما داده‌های این حساب را از این دستگاه حذف کرد. برای شروع دوباره وارد شوید.", + "note_unsent": "سازمان شما داده‌های این حساب را از این دستگاه حذف کرد. پیام‌های ارسال‌نشدهٔ حذف‌شده: {count}.", + "settings_title": "حذف همه چیز از این دستگاه", + "settings_description": "برای زمانی که این رایانه را به کسی می‌دهید یا دیگر از آن استفاده نمی‌کنید. نامه‌های شما روی سرور می‌ماند.", + "settings_button": "حذف همه چیز…", + "settings_wiping": "در حال حذف…", + "confirm_title": "همه چیز از این دستگاه حذف شود؟", + "confirm_message": "این کار آنچه این برنامه روی این دستگاه نگه می‌دارد را برای همهٔ حساب‌های اینجا حذف می‌کند. به نامه‌های شما روی سرور دست زده نمی‌شود.", + "confirm_mail": "نامه‌ها، تقویم‌ها، مخاطبان و فایل‌های نگه‌داری‌شده در اینجا", + "confirm_unsent": "پیام‌های ارسال‌نشدهٔ در انتظار در اینجا: {count}. از بین خواهند رفت.", + "confirm_keys": "کلیدهای S/MIME و OpenPGP. برای استفادهٔ دوباره به نسخهٔ پشتیبان خودتان نیاز دارید.", + "confirm_plugins": "داده‌هایی که افزونه‌ها اینجا ذخیره کرده‌اند", + "confirm_files": "پیوست‌های بازشده برای ویرایش و فایل‌های در انتظار پیوست", + "confirm_accounts": "از همهٔ حساب‌ها خارج می‌شوید", + "confirm_button": "حذف همه چیز" + }, "offlineDrafts": { "title": "پیش‌نویس‌های این دستگاه", "description": "این پیش‌نویس‌ها روی این دستگاه ذخیره شده‌اند. به‌محض دسترسی به سرور، خودکار به آن می‌روند.", diff --git a/locales/fr/common.json b/locales/fr/common.json index e30800b16..0c9189314 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5763,6 +5763,23 @@ "store_private": "Les fenêtres privées ne peuvent pas conserver le courrier pour une utilisation hors ligne.", "server_not_answering": "Le serveur de messagerie ne répond pas. Réessayez dans un instant." }, + "offlineWipe": { + "note": "Votre organisation a supprimé les données de ce compte de cet appareil. Connectez-vous pour recommencer.", + "note_unsent": "Votre organisation a supprimé les données de ce compte de cet appareil. Messages non envoyés supprimés : {count}.", + "settings_title": "Tout supprimer de cet appareil", + "settings_description": "Si vous donnez cet ordinateur ou cessez de l'utiliser. Vos messages sur le serveur restent en place.", + "settings_button": "Tout supprimer…", + "settings_wiping": "Suppression…", + "confirm_title": "Tout supprimer de cet appareil ?", + "confirm_message": "Cela supprime ce que cette application garde sur cet appareil, pour tous les comptes présents. Vos messages sur le serveur ne sont pas touchés.", + "confirm_mail": "Messages, agendas, contacts et fichiers gardés ici", + "confirm_unsent": "Messages non envoyés en attente ici : {count}. Ils seront perdus.", + "confirm_keys": "Clés S/MIME et OpenPGP. Il vous faudra votre propre sauvegarde pour les réutiliser.", + "confirm_plugins": "Données enregistrées ici par les plugins", + "confirm_files": "Pièces jointes ouvertes pour modification et fichiers en attente d'être joints", + "confirm_accounts": "Tous les comptes sont déconnectés", + "confirm_button": "Tout supprimer" + }, "offlineDrafts": { "title": "Brouillons sur cet appareil", "description": "Ces brouillons sont enregistrés sur cet appareil. Ils partent seuls vers le serveur dès qu'il est joignable.", diff --git a/locales/he/common.json b/locales/he/common.json index 571f30e34..2591789ac 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5764,6 +5764,23 @@ "store_private": "חלונות פרטיים לא יכולים לשמור דואר לשימוש לא מקוון.", "server_not_answering": "שרת הדואר לא עונה. אפשר לנסות שוב בעוד רגע." }, + "offlineWipe": { + "note": "הארגון שלך הסיר את נתוני החשבון הזה מהמכשיר הזה. יש להיכנס כדי להתחיל מחדש.", + "note_unsent": "הארגון שלך הסיר את נתוני החשבון הזה מהמכשיר הזה. הודעות שלא נשלחו ונמחקו: {count}.", + "settings_title": "הסרת הכול מהמכשיר הזה", + "settings_description": "למקרה שמעבירים את המחשב הזה למישהו אחר או מפסיקים להשתמש בו. הדואר שלך בשרת נשאר.", + "settings_button": "הסרת הכול…", + "settings_wiping": "מתבצעת הסרה…", + "confirm_title": "להסיר הכול מהמכשיר הזה?", + "confirm_message": "פעולה זו מוחקת את מה שהיישום הזה שומר במכשיר הזה, לכל החשבונות כאן. הדואר שלך בשרת לא ייפגע.", + "confirm_mail": "דואר, יומנים, אנשי קשר וקבצים השמורים כאן", + "confirm_unsent": "הודעות שלא נשלחו וממתינות כאן: {count}. הן יאבדו.", + "confirm_keys": "מפתחות S/MIME ו-OpenPGP. כדי להשתמש בהם שוב צריך גיבוי משלך.", + "confirm_plugins": "נתונים שתוספים שמרו כאן", + "confirm_files": "קבצים מצורפים שנפתחו לעריכה וקבצים שממתינים לצירוף", + "confirm_accounts": "מתבצעת יציאה מכל החשבונות", + "confirm_button": "הסרת הכול" + }, "offlineDrafts": { "title": "טיוטות במכשיר זה", "description": "הטיוטות האלה שמורות במכשיר זה. הן יעברו לשרת מעצמן ברגע שאפשר יהיה להגיע אליו.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2ab59129e..6df1dbb69 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5763,6 +5763,23 @@ "store_private": "A privát ablakok nem tudnak leveleket megőrizni offline használatra.", "server_not_answering": "A levelezőszerver nem válaszol. Próbálja újra egy pillanat múlva." }, + "offlineWipe": { + "note": "A szervezete eltávolította a fiók adatait erről az eszközről. Jelentkezzen be az újrakezdéshez.", + "note_unsent": "A szervezete eltávolította a fiók adatait erről az eszközről. Törölt, el nem küldött üzenetek: {count}.", + "settings_title": "Minden eltávolítása erről az eszközről", + "settings_description": "Arra az esetre, ha továbbadja ezt a számítógépet, vagy már nem használja. A levelei a kiszolgálón megmaradnak.", + "settings_button": "Minden eltávolítása…", + "settings_wiping": "Eltávolítás…", + "confirm_title": "Eltávolít mindent erről az eszközről?", + "confirm_message": "Ez törli mindazt, amit ez az alkalmazás ezen az eszközön tárol, az itteni összes fiókhoz. A kiszolgálón lévő leveleit nem érinti.", + "confirm_mail": "Az itt tárolt levelek, naptárak, névjegyek és fájlok", + "confirm_unsent": "Itt várakozó, el nem küldött üzenetek: {count}. Elvesznek.", + "confirm_keys": "S/MIME- és OpenPGP-kulcsok. Az újbóli használatukhoz saját biztonsági másolatra lesz szüksége.", + "confirm_plugins": "A bővítmények által itt tárolt adatok", + "confirm_files": "Szerkesztésre megnyitott mellékletek és csatolásra váró fájlok", + "confirm_accounts": "Minden fiók kijelentkezik", + "confirm_button": "Minden eltávolítása" + }, "offlineDrafts": { "title": "Piszkozatok ezen az eszközön", "description": "Ezek a piszkozatok ezen az eszközön vannak mentve. Maguktól a szerverre kerülnek, amint az elérhető.", diff --git a/locales/it/common.json b/locales/it/common.json index a861d3390..72eab5bb2 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5763,6 +5763,23 @@ "store_private": "Le finestre private non possono conservare la posta per l'uso offline.", "server_not_answering": "Il server di posta non risponde. Riprova tra un momento." }, + "offlineWipe": { + "note": "La tua organizzazione ha rimosso i dati di questo account da questo dispositivo. Accedi per ricominciare.", + "note_unsent": "La tua organizzazione ha rimosso i dati di questo account da questo dispositivo. Messaggi non inviati eliminati: {count}.", + "settings_title": "Rimuovi tutto da questo dispositivo", + "settings_description": "Per quando cedi questo computer o smetti di usarlo. La tua posta sul server resta.", + "settings_button": "Rimuovi tutto…", + "settings_wiping": "Rimozione…", + "confirm_title": "Rimuovere tutto da questo dispositivo?", + "confirm_message": "Questo elimina ciò che questa app conserva su questo dispositivo, per tutti gli account presenti. La tua posta sul server non viene toccata.", + "confirm_mail": "Posta, calendari, contatti e file conservati qui", + "confirm_unsent": "Messaggi non inviati in attesa qui: {count}. Andranno persi.", + "confirm_keys": "Chiavi S/MIME e OpenPGP. Per riusarle ti serve un tuo backup.", + "confirm_plugins": "Dati salvati qui dai plugin", + "confirm_files": "Allegati aperti per la modifica e file in attesa di essere allegati", + "confirm_accounts": "Tutti gli account vengono disconnessi", + "confirm_button": "Rimuovi tutto" + }, "offlineDrafts": { "title": "Bozze su questo dispositivo", "description": "Queste bozze sono salvate su questo dispositivo. Passano da sole al server appena è raggiungibile.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0606617f1..067d7339b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5763,6 +5763,23 @@ "store_private": "プライベートウィンドウではオフライン用にメールを保存できません。", "server_not_answering": "メールサーバーが応答していません。少ししてからもう一度お試しください。" }, + "offlineWipe": { + "note": "組織がこのアカウントのデータをこのデバイスから削除しました。もう一度始めるにはサインインしてください。", + "note_unsent": "組織がこのアカウントのデータをこのデバイスから削除しました。削除された未送信メッセージ: {count} 件。", + "settings_title": "このデバイスからすべて削除", + "settings_description": "このコンピューターを譲渡するときや使わなくなるときのためのものです。サーバー上のメールは残ります。", + "settings_button": "すべて削除…", + "settings_wiping": "削除しています…", + "confirm_title": "このデバイスからすべて削除しますか?", + "confirm_message": "このアプリがこのデバイスに保存している内容を、ここにあるすべてのアカウントについて削除します。サーバー上のメールには影響しません。", + "confirm_mail": "ここに保存されたメール、カレンダー、連絡先、ファイル", + "confirm_unsent": "ここで待機中の未送信メッセージ: {count} 件。失われます。", + "confirm_keys": "S/MIME と OpenPGP の鍵。再度使うにはご自身のバックアップが必要です。", + "confirm_plugins": "プラグインがここに保存したデータ", + "confirm_files": "編集のために開いた添付ファイルと、添付待ちのファイル", + "confirm_accounts": "すべてのアカウントがサインアウトされます", + "confirm_button": "すべて削除" + }, "offlineDrafts": { "title": "このデバイスの下書き", "description": "これらの下書きはこのデバイスに保存されています。サーバーに接続できるようになると自動で送られます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 7e0daa574..139a447fa 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5763,6 +5763,23 @@ "store_private": "개인 정보 보호 창에서는 오프라인용 메일을 보관할 수 없습니다.", "server_not_answering": "메일 서버가 응답하지 않습니다. 잠시 후 다시 시도하세요." }, + "offlineWipe": { + "note": "조직에서 이 기기의 이 계정 데이터를 제거했습니다. 다시 시작하려면 로그인하세요.", + "note_unsent": "조직에서 이 기기의 이 계정 데이터를 제거했습니다. 삭제된 보내지 않은 메시지: {count}개.", + "settings_title": "이 기기에서 모두 제거", + "settings_description": "이 컴퓨터를 다른 사람에게 주거나 더 이상 사용하지 않을 때 사용합니다. 서버의 메일은 그대로 남습니다.", + "settings_button": "모두 제거…", + "settings_wiping": "제거하는 중…", + "confirm_title": "이 기기에서 모두 제거할까요?", + "confirm_message": "이 앱이 이 기기에 보관하는 내용을 여기 있는 모든 계정에 대해 삭제합니다. 서버의 메일은 건드리지 않습니다.", + "confirm_mail": "여기에 보관된 메일, 캘린더, 연락처, 파일", + "confirm_unsent": "여기에서 대기 중인 보내지 않은 메시지: {count}개. 사라집니다.", + "confirm_keys": "S/MIME 및 OpenPGP 키. 다시 사용하려면 직접 만든 백업이 필요합니다.", + "confirm_plugins": "플러그인이 여기에 저장한 데이터", + "confirm_files": "편집하려고 연 첨부 파일과 첨부를 기다리는 파일", + "confirm_accounts": "모든 계정이 로그아웃됩니다", + "confirm_button": "모두 제거" + }, "offlineDrafts": { "title": "이 기기의 임시 보관함", "description": "이 임시 보관 메일은 이 기기에 저장되어 있습니다. 서버에 연결되면 자동으로 올라갑니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 05939fbc0..74592824c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5763,6 +5763,23 @@ "store_private": "Privātie logi nevar glabāt pastu lietošanai bezsaistē.", "server_not_answering": "Pasta serveris neatbild. Mēģiniet vēlreiz pēc brīža." }, + "offlineWipe": { + "note": "Jūsu organizācija ir noņēmusi šī konta datus no šīs ierīces. Piesakieties, lai sāktu no jauna.", + "note_unsent": "Jūsu organizācija ir noņēmusi šī konta datus no šīs ierīces. Dzēstie nenosūtītie ziņojumi: {count}.", + "settings_title": "Noņemt visu no šīs ierīces", + "settings_description": "Gadījumam, ja atdodat šo datoru vai vairs to neizmantojat. Jūsu pasts serverī paliek.", + "settings_button": "Noņemt visu…", + "settings_wiping": "Notiek noņemšana…", + "confirm_title": "Vai noņemt visu no šīs ierīces?", + "confirm_message": "Tiek izdzēsts tas, ko šī lietotne glabā šajā ierīcē, visiem šeit esošajiem kontiem. Jūsu pasts serverī netiek skarts.", + "confirm_mail": "Šeit glabātais pasts, kalendāri, kontakti un faili", + "confirm_unsent": "Šeit gaidošie nenosūtītie ziņojumi: {count}. Tie tiks zaudēti.", + "confirm_keys": "S/MIME un OpenPGP atslēgas. Lai tās izmantotu atkal, būs vajadzīga jūsu pašu dublējumkopija.", + "confirm_plugins": "Dati, ko šeit saglabājuši spraudņi", + "confirm_files": "Rediģēšanai atvērti pielikumi un faili, kas gaida pievienošanu", + "confirm_accounts": "Visi konti tiek izrakstīti", + "confirm_button": "Noņemt visu" + }, "offlineDrafts": { "title": "Melnraksti šajā ierīcē", "description": "Šie melnraksti ir saglabāti šajā ierīcē. Tie paši nonāks serverī, tiklīdz tas būs sasniedzams.", diff --git a/locales/mn/common.json b/locales/mn/common.json index dfa942d34..c325bdfd6 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5763,6 +5763,23 @@ "store_private": "Хувийн цонх офлайн ашиглах захидал хадгалж чадахгүй.", "server_not_answering": "Шуудангийн сервер хариу өгөхгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу." }, + "offlineWipe": { + "note": "Таны байгууллага энэ бүртгэлийн өгөгдлийг энэ төхөөрөмжөөс устгасан. Дахин эхлэхийн тулд нэвтэрнэ үү.", + "note_unsent": "Таны байгууллага энэ бүртгэлийн өгөгдлийг энэ төхөөрөмжөөс устгасан. Устгасан илгээгээгүй зурвас: {count}.", + "settings_title": "Энэ төхөөрөмжөөс бүгдийг устгах", + "settings_description": "Энэ компьютерийг бусдад өгөх эсвэл ашиглахаа болих үед. Сервер дээрх таны шуудан хэвээр үлдэнэ.", + "settings_button": "Бүгдийг устгах…", + "settings_wiping": "Устгаж байна…", + "confirm_title": "Энэ төхөөрөмжөөс бүгдийг устгах уу?", + "confirm_message": "Энэ аппын энэ төхөөрөмж дээр хадгалсан бүх зүйлийг эндхийн бүх бүртгэлд устгана. Сервер дээрх таны шуудан хөндөгдөхгүй.", + "confirm_mail": "Энд хадгалсан шуудан, хуанли, харилцагч, файлууд", + "confirm_unsent": "Энд хүлээгдэж буй илгээгээгүй зурвас: {count}. Тэд алга болно.", + "confirm_keys": "S/MIME болон OpenPGP түлхүүрүүд. Дахин ашиглахын тулд өөрийн нөөц хуулбар хэрэгтэй.", + "confirm_plugins": "Өргөтгөлүүдийн энд хадгалсан өгөгдөл", + "confirm_files": "Засахаар нээсэн хавсралтууд болон хавсаргахаар хүлээж буй файлууд", + "confirm_accounts": "Бүх бүртгэлээс гарна", + "confirm_button": "Бүгдийг устгах" + }, "offlineDrafts": { "title": "Энэ төхөөрөмж дээрх ноорог", "description": "Эдгээр ноорог энэ төхөөрөмж дээр хадгалагдсан. Сервертэй холбогдмогц өөрсдөө илгээгдэнэ.", diff --git a/locales/nb/common.json b/locales/nb/common.json index a0002acca..b0fe115da 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5763,6 +5763,23 @@ "store_private": "Private vinduer kan ikke beholde e-post for bruk uten nett.", "server_not_answering": "E-postserveren svarer ikke. Prøv igjen om litt." }, + "offlineWipe": { + "note": "Organisasjonen din har fjernet dataene for denne kontoen fra denne enheten. Logg på for å begynne på nytt.", + "note_unsent": "Organisasjonen din har fjernet dataene for denne kontoen fra denne enheten. Slettede usendte meldinger: {count}.", + "settings_title": "Fjern alt fra denne enheten", + "settings_description": "For når du gir bort denne datamaskinen eller slutter å bruke den. E-posten din på serveren blir værende.", + "settings_button": "Fjern alt…", + "settings_wiping": "Fjerner…", + "confirm_title": "Vil du fjerne alt fra denne enheten?", + "confirm_message": "Dette sletter det denne appen lagrer på denne enheten, for alle kontoer her. E-posten din på serveren blir ikke rørt.", + "confirm_mail": "E-post, kalendere, kontakter og filer som er lagret her", + "confirm_unsent": "Usendte meldinger som venter her: {count}. De går tapt.", + "confirm_keys": "S/MIME- og OpenPGP-nøkler. Du trenger din egen sikkerhetskopi for å bruke dem igjen.", + "confirm_plugins": "Data som programtillegg har lagret her", + "confirm_files": "Vedlegg som er åpnet for redigering, og filer som venter på å bli lagt ved", + "confirm_accounts": "Alle kontoer logges av", + "confirm_button": "Fjern alt" + }, "offlineDrafts": { "title": "Utkast på denne enheten", "description": "Disse utkastene er lagret på denne enheten. De går til serveren av seg selv så snart den kan nås.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 5524a36da..296c240c3 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5763,6 +5763,23 @@ "store_private": "Privévensters kunnen geen mail bewaren voor offline gebruik.", "server_not_answering": "De mailserver antwoordt niet. Probeer het zo meteen opnieuw." }, + "offlineWipe": { + "note": "Je organisatie heeft de gegevens van dit account van dit apparaat verwijderd. Meld je aan om opnieuw te beginnen.", + "note_unsent": "Je organisatie heeft de gegevens van dit account van dit apparaat verwijderd. Verwijderde onverzonden berichten: {count}.", + "settings_title": "Alles van dit apparaat verwijderen", + "settings_description": "Voor als je deze computer weggeeft of niet meer gebruikt. Je e-mail op de server blijft staan.", + "settings_button": "Alles verwijderen…", + "settings_wiping": "Verwijderen…", + "confirm_title": "Alles van dit apparaat verwijderen?", + "confirm_message": "Dit verwijdert wat deze app op dit apparaat bewaart, voor alle accounts hier. Je e-mail op de server blijft ongemoeid.", + "confirm_mail": "E-mail, agenda's, contacten en bestanden die hier bewaard zijn", + "confirm_unsent": "Onverzonden berichten die hier wachten: {count}. Die gaan verloren.", + "confirm_keys": "S/MIME- en OpenPGP-sleutels. Om ze weer te gebruiken heb je je eigen back-up nodig.", + "confirm_plugins": "Gegevens die plug-ins hier hebben opgeslagen", + "confirm_files": "Bijlagen die zijn geopend om te bewerken en bestanden die nog worden bijgevoegd", + "confirm_accounts": "Alle accounts worden afgemeld", + "confirm_button": "Alles verwijderen" + }, "offlineDrafts": { "title": "Concepten op dit apparaat", "description": "Deze concepten zijn op dit apparaat opgeslagen. Ze gaan vanzelf naar de server zodra die bereikbaar is.", diff --git a/locales/pl/common.json b/locales/pl/common.json index fa5b5481e..d9a95b794 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5763,6 +5763,23 @@ "store_private": "Okna prywatne nie mogą przechowywać poczty do użytku offline.", "server_not_answering": "Serwer poczty nie odpowiada. Spróbuj ponownie za chwilę." }, + "offlineWipe": { + "note": "Twoja organizacja usunęła dane tego konta z tego urządzenia. Zaloguj się, aby zacząć od nowa.", + "note_unsent": "Twoja organizacja usunęła dane tego konta z tego urządzenia. Usunięte niewysłane wiadomości: {count}.", + "settings_title": "Usuń wszystko z tego urządzenia", + "settings_description": "Na wypadek, gdy oddajesz ten komputer lub przestajesz go używać. Twoja poczta na serwerze zostaje.", + "settings_button": "Usuń wszystko…", + "settings_wiping": "Usuwanie…", + "confirm_title": "Usunąć wszystko z tego urządzenia?", + "confirm_message": "Spowoduje to usunięcie tego, co ta aplikacja przechowuje na tym urządzeniu, dla wszystkich kont. Twoja poczta na serwerze pozostanie nietknięta.", + "confirm_mail": "Poczta, kalendarze, kontakty i pliki przechowywane tutaj", + "confirm_unsent": "Niewysłane wiadomości czekające tutaj: {count}. Zostaną utracone.", + "confirm_keys": "Klucze S/MIME i OpenPGP. Aby znów ich używać, potrzebujesz własnej kopii zapasowej.", + "confirm_plugins": "Dane zapisane tutaj przez wtyczki", + "confirm_files": "Załączniki otwarte do edycji i pliki czekające na dołączenie", + "confirm_accounts": "Wszystkie konta zostaną wylogowane", + "confirm_button": "Usuń wszystko" + }, "offlineDrafts": { "title": "Wersje robocze na tym urządzeniu", "description": "Te wersje robocze są zapisane na tym urządzeniu. Trafią na serwer same, gdy tylko będzie dostępny.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 87a2e2ff1..a1e44c41f 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5763,6 +5763,23 @@ "store_private": "As janelas privadas não podem guardar correio para uso offline.", "server_not_answering": "O servidor de correio não está a responder. Tente novamente daqui a pouco." }, + "offlineWipe": { + "note": "A sua organização removeu os dados desta conta deste dispositivo. Inicie sessão para começar de novo.", + "note_unsent": "A sua organização removeu os dados desta conta deste dispositivo. Mensagens não enviadas eliminadas: {count}.", + "settings_title": "Remover tudo deste dispositivo", + "settings_description": "Para quando oferecer este computador ou deixar de o usar. O seu correio no servidor mantém-se.", + "settings_button": "Remover tudo…", + "settings_wiping": "A remover…", + "confirm_title": "Remover tudo deste dispositivo?", + "confirm_message": "Isto elimina o que esta aplicação guarda neste dispositivo, para todas as contas aqui. O seu correio no servidor não é tocado.", + "confirm_mail": "Correio, calendários, contactos e ficheiros guardados aqui", + "confirm_unsent": "Mensagens não enviadas em espera aqui: {count}. Serão perdidas.", + "confirm_keys": "Chaves S/MIME e OpenPGP. Precisa da sua própria cópia de segurança para as voltar a usar.", + "confirm_plugins": "Dados que os plugins guardaram aqui", + "confirm_files": "Anexos abertos para edição e ficheiros à espera de serem anexados", + "confirm_accounts": "Todas as contas terminam sessão", + "confirm_button": "Remover tudo" + }, "offlineDrafts": { "title": "Rascunhos neste dispositivo", "description": "Estes rascunhos estão guardados neste dispositivo. Vão para o servidor por si assim que for possível contactá-lo.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 80c31fa28..9e6b568d3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5763,6 +5763,23 @@ "store_private": "Ferestrele private nu pot păstra e-mailuri pentru utilizare offline.", "server_not_answering": "Serverul de e-mail nu răspunde. Încercați din nou imediat." }, + "offlineWipe": { + "note": "Organizația dvs. a eliminat datele acestui cont de pe acest dispozitiv. Conectați-vă pentru a începe din nou.", + "note_unsent": "Organizația dvs. a eliminat datele acestui cont de pe acest dispozitiv. Mesaje netrimise șterse: {count}.", + "settings_title": "Elimină tot de pe acest dispozitiv", + "settings_description": "Pentru când dați acest computer altcuiva sau nu îl mai folosiți. E-mailurile de pe server rămân.", + "settings_button": "Elimină tot…", + "settings_wiping": "Se elimină…", + "confirm_title": "Eliminați tot de pe acest dispozitiv?", + "confirm_message": "Se șterge ceea ce această aplicație păstrează pe acest dispozitiv, pentru toate conturile de aici. E-mailurile de pe server nu sunt atinse.", + "confirm_mail": "E-mailuri, calendare, contacte și fișiere păstrate aici", + "confirm_unsent": "Mesaje netrimise care așteaptă aici: {count}. Se vor pierde.", + "confirm_keys": "Chei S/MIME și OpenPGP. Pentru a le folosi din nou aveți nevoie de propria copie de rezervă.", + "confirm_plugins": "Date stocate aici de pluginuri", + "confirm_files": "Atașamente deschise pentru editare și fișiere care așteaptă să fie atașate", + "confirm_accounts": "Toate conturile sunt deconectate", + "confirm_button": "Elimină tot" + }, "offlineDrafts": { "title": "Ciorne pe acest dispozitiv", "description": "Aceste ciorne sunt salvate pe acest dispozitiv. Ajung singure pe server imediat ce poate fi contactat.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 55510f4c8..748e8b21c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5763,6 +5763,23 @@ "store_private": "Приватные окна не могут хранить почту для работы офлайн.", "server_not_answering": "Почтовый сервер не отвечает. Повторите попытку через минуту." }, + "offlineWipe": { + "note": "Ваша организация удалила данные этой учётной записи с этого устройства. Войдите, чтобы начать заново.", + "note_unsent": "Ваша организация удалила данные этой учётной записи с этого устройства. Удалено неотправленных сообщений: {count}.", + "settings_title": "Удалить всё с этого устройства", + "settings_description": "На случай, если вы передаёте этот компьютер или больше им не пользуетесь. Ваша почта на сервере останется.", + "settings_button": "Удалить всё…", + "settings_wiping": "Удаление…", + "confirm_title": "Удалить всё с этого устройства?", + "confirm_message": "Будет удалено всё, что это приложение хранит на этом устройстве, для всех учётных записей здесь. Почта на сервере не будет затронута.", + "confirm_mail": "Почта, календари, контакты и файлы, хранящиеся здесь", + "confirm_unsent": "Неотправленных сообщений в ожидании: {count}. Они будут потеряны.", + "confirm_keys": "Ключи S/MIME и OpenPGP. Чтобы снова ими пользоваться, понадобится ваша собственная резервная копия.", + "confirm_plugins": "Данные, сохранённые здесь плагинами", + "confirm_files": "Вложения, открытые для редактирования, и файлы, ожидающие прикрепления", + "confirm_accounts": "Выполняется выход из всех учётных записей", + "confirm_button": "Удалить всё" + }, "offlineDrafts": { "title": "Черновики на этом устройстве", "description": "Эти черновики сохранены на этом устройстве. Они сами уйдут на сервер, как только он станет доступен.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 9e12b12d2..8d0de3334 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5763,6 +5763,23 @@ "store_private": "Súkromné okná nemôžu uchovávať poštu na používanie offline.", "server_not_answering": "Poštový server neodpovedá. Skúste to o chvíľu znova." }, + "offlineWipe": { + "note": "Vaša organizácia odstránila údaje tohto účtu z tohto zariadenia. Prihláste sa a začnite znova.", + "note_unsent": "Vaša organizácia odstránila údaje tohto účtu z tohto zariadenia. Odstránené neodoslané správy: {count}.", + "settings_title": "Odstrániť všetko z tohto zariadenia", + "settings_description": "Pre prípad, že tento počítač odovzdáte ďalej alebo ho prestanete používať. Vaša pošta na serveri zostane.", + "settings_button": "Odstrániť všetko…", + "settings_wiping": "Odstraňuje sa…", + "confirm_title": "Odstrániť všetko z tohto zariadenia?", + "confirm_message": "Tým sa odstráni to, čo táto aplikácia uchováva v tomto zariadení, pre všetky tunajšie účty. Vašej pošty na serveri sa to nedotkne.", + "confirm_mail": "Pošta, kalendáre, kontakty a súbory uložené tu", + "confirm_unsent": "Neodoslané správy čakajúce tu: {count}. Stratia sa.", + "confirm_keys": "Kľúče S/MIME a OpenPGP. Na ich ďalšie použitie budete potrebovať vlastnú zálohu.", + "confirm_plugins": "Údaje, ktoré sem uložili doplnky", + "confirm_files": "Prílohy otvorené na úpravy a súbory čakajúce na priloženie", + "confirm_accounts": "Všetky účty sa odhlásia", + "confirm_button": "Odstrániť všetko" + }, "offlineDrafts": { "title": "Koncepty v tomto zariadení", "description": "Tieto koncepty sú uložené v tomto zariadení. Na server sa odošlú samy, hneď ako bude dostupný.", diff --git a/locales/th/common.json b/locales/th/common.json index 0be3db9db..549007876 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2108,6 +2108,23 @@ "store_private": "หน้าต่างส่วนตัวเก็บอีเมลไว้ใช้แบบออฟไลน์ไม่ได้", "server_not_answering": "เซิร์ฟเวอร์อีเมลไม่ตอบสนอง โปรดลองอีกครั้งในอีกสักครู่" }, + "offlineWipe": { + "note": "องค์กรของคุณนำข้อมูลของบัญชีนี้ออกจากอุปกรณ์นี้แล้ว ลงชื่อเข้าใช้เพื่อเริ่มใหม่", + "note_unsent": "องค์กรของคุณนำข้อมูลของบัญชีนี้ออกจากอุปกรณ์นี้แล้ว ข้อความที่ยังไม่ส่งที่ถูกลบ: {count}", + "settings_title": "นำทุกอย่างออกจากอุปกรณ์นี้", + "settings_description": "สำหรับเมื่อคุณจะยกคอมพิวเตอร์เครื่องนี้ให้ผู้อื่นหรือเลิกใช้ อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", + "settings_button": "นำทุกอย่างออก…", + "settings_wiping": "กำลังนำออก…", + "confirm_title": "นำทุกอย่างออกจากอุปกรณ์นี้หรือไม่", + "confirm_message": "การทำเช่นนี้จะลบสิ่งที่แอปนี้เก็บไว้ในอุปกรณ์นี้ สำหรับทุกบัญชีที่นี่ อีเมลของคุณบนเซิร์ฟเวอร์จะไม่ถูกแตะต้อง", + "confirm_mail": "อีเมล ปฏิทิน รายชื่อ และไฟล์ที่เก็บไว้ที่นี่", + "confirm_unsent": "ข้อความที่ยังไม่ส่งที่รออยู่ที่นี่: {count} ข้อความเหล่านี้จะสูญหาย", + "confirm_keys": "คีย์ S/MIME และ OpenPGP คุณต้องมีข้อมูลสำรองของคุณเองเพื่อใช้อีกครั้ง", + "confirm_plugins": "ข้อมูลที่ปลั๊กอินเก็บไว้ที่นี่", + "confirm_files": "ไฟล์แนบที่เปิดเพื่อแก้ไขและไฟล์ที่รอแนบ", + "confirm_accounts": "ทุกบัญชีจะออกจากระบบ", + "confirm_button": "นำทุกอย่างออก" + }, "offlineDrafts": { "title": "ฉบับร่างในอุปกรณ์นี้", "description": "ฉบับร่างเหล่านี้บันทึกไว้ในอุปกรณ์นี้ และจะส่งไปยังเซิร์ฟเวอร์เองเมื่อเชื่อมต่อได้", diff --git a/locales/tr/common.json b/locales/tr/common.json index fa74d9864..f10139b57 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5763,6 +5763,23 @@ "store_private": "Gizli pencereler e-postaları çevrimdışı kullanım için saklayamaz.", "server_not_answering": "E-posta sunucusu yanıt vermiyor. Birazdan yeniden deneyin." }, + "offlineWipe": { + "note": "Kuruluşunuz bu hesabın verilerini bu cihazdan kaldırdı. Yeniden başlamak için oturum açın.", + "note_unsent": "Kuruluşunuz bu hesabın verilerini bu cihazdan kaldırdı. Silinen gönderilmemiş iletiler: {count}.", + "settings_title": "Bu cihazdan her şeyi kaldır", + "settings_description": "Bu bilgisayarı başkasına verdiğinizde veya kullanmayı bıraktığınızda. Sunucudaki postalarınız kalır.", + "settings_button": "Her şeyi kaldır…", + "settings_wiping": "Kaldırılıyor…", + "confirm_title": "Bu cihazdan her şey kaldırılsın mı?", + "confirm_message": "Bu işlem, bu uygulamanın buradaki tüm hesaplar için bu cihazda tuttuklarını siler. Sunucudaki postalarınıza dokunulmaz.", + "confirm_mail": "Burada tutulan postalar, takvimler, kişiler ve dosyalar", + "confirm_unsent": "Burada bekleyen gönderilmemiş iletiler: {count}. Kaybolacaklar.", + "confirm_keys": "S/MIME ve OpenPGP anahtarları. Yeniden kullanmak için kendi yedeğiniz gerekir.", + "confirm_plugins": "Eklentilerin burada sakladığı veriler", + "confirm_files": "Düzenlemek için açılan ekler ve eklenmeyi bekleyen dosyalar", + "confirm_accounts": "Tüm hesapların oturumu kapatılır", + "confirm_button": "Her şeyi kaldır" + }, "offlineDrafts": { "title": "Bu cihazdaki taslaklar", "description": "Bu taslaklar bu cihaza kaydedildi. Sunucuya ulaşılır ulaşılmaz kendiliğinden gönderilir.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 391a5c203..752fcbec6 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5763,6 +5763,23 @@ "store_private": "Приватні вікна не можуть зберігати пошту для роботи офлайн.", "server_not_answering": "Поштовий сервер не відповідає. Спробуйте ще раз за хвилину." }, + "offlineWipe": { + "note": "Ваша організація видалила дані цього облікового запису з цього пристрою. Увійдіть, щоб почати знову.", + "note_unsent": "Ваша організація видалила дані цього облікового запису з цього пристрою. Видалено ненадісланих повідомлень: {count}.", + "settings_title": "Видалити все з цього пристрою", + "settings_description": "На випадок, якщо ви передаєте цей комп'ютер або більше ним не користуєтеся. Ваша пошта на сервері залишиться.", + "settings_button": "Видалити все…", + "settings_wiping": "Видалення…", + "confirm_title": "Видалити все з цього пристрою?", + "confirm_message": "Буде видалено все, що цей застосунок зберігає на цьому пристрої, для всіх облікових записів тут. Пошту на сервері це не зачепить.", + "confirm_mail": "Пошта, календарі, контакти та файли, що зберігаються тут", + "confirm_unsent": "Ненадісланих повідомлень в очікуванні: {count}. Вони будуть втрачені.", + "confirm_keys": "Ключі S/MIME та OpenPGP. Щоб знову ними користуватися, потрібна ваша власна резервна копія.", + "confirm_plugins": "Дані, збережені тут плагінами", + "confirm_files": "Вкладення, відкриті для редагування, і файли, що очікують прикріплення", + "confirm_accounts": "Вихід з усіх облікових записів", + "confirm_button": "Видалити все" + }, "offlineDrafts": { "title": "Чернетки на цьому пристрої", "description": "Ці чернетки збережено на цьому пристрої. Вони самі підуть на сервер, щойно він стане доступним.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 347fe2cd0..d5fd42356 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5775,6 +5775,23 @@ "store_private": "Cửa sổ riêng tư không thể giữ thư để dùng ngoại tuyến.", "server_not_answering": "Máy chủ thư không phản hồi. Hãy thử lại sau giây lát." }, + "offlineWipe": { + "note": "Tổ chức của bạn đã xóa dữ liệu của tài khoản này khỏi thiết bị này. Hãy đăng nhập để bắt đầu lại.", + "note_unsent": "Tổ chức của bạn đã xóa dữ liệu của tài khoản này khỏi thiết bị này. Số thư chưa gửi đã xóa: {count}.", + "settings_title": "Xóa mọi thứ khỏi thiết bị này", + "settings_description": "Dành cho khi bạn tặng máy tính này hoặc không dùng nữa. Thư của bạn trên máy chủ vẫn còn.", + "settings_button": "Xóa mọi thứ…", + "settings_wiping": "Đang xóa…", + "confirm_title": "Xóa mọi thứ khỏi thiết bị này?", + "confirm_message": "Thao tác này xóa những gì ứng dụng này lưu trên thiết bị này, cho mọi tài khoản ở đây. Thư của bạn trên máy chủ không bị ảnh hưởng.", + "confirm_mail": "Thư, lịch, danh bạ và tệp được lưu ở đây", + "confirm_unsent": "Số thư chưa gửi đang chờ ở đây: {count}. Chúng sẽ bị mất.", + "confirm_keys": "Khóa S/MIME và OpenPGP. Bạn cần bản sao lưu của riêng mình để dùng lại.", + "confirm_plugins": "Dữ liệu mà các tiện ích đã lưu ở đây", + "confirm_files": "Tệp đính kèm mở để chỉnh sửa và tệp đang chờ đính kèm", + "confirm_accounts": "Tất cả tài khoản sẽ đăng xuất", + "confirm_button": "Xóa mọi thứ" + }, "offlineDrafts": { "title": "Bản nháp trên thiết bị này", "description": "Các bản nháp này được lưu trên thiết bị này. Chúng sẽ tự chuyển lên máy chủ ngay khi kết nối được.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 97dacbb06..79472b28c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5763,6 +5763,23 @@ "store_private": "私密視窗無法保留供離線使用的郵件。", "server_not_answering": "郵件伺服器沒有回應。請稍後再試。" }, + "offlineWipe": { + "note": "您的組織已從此裝置移除此帳號的資料。請登入以重新開始。", + "note_unsent": "您的組織已從此裝置移除此帳號的資料。已刪除的未傳送郵件:{count} 封。", + "settings_title": "從此裝置移除所有內容", + "settings_description": "用於將這台電腦送人或不再使用時。伺服器上的郵件會保留。", + "settings_button": "移除所有內容…", + "settings_wiping": "正在移除…", + "confirm_title": "要從此裝置移除所有內容嗎?", + "confirm_message": "這會刪除此應用程式為這裡所有帳號儲存在此裝置上的內容。伺服器上的郵件不受影響。", + "confirm_mail": "儲存在這裡的郵件、行事曆、聯絡人和檔案", + "confirm_unsent": "在這裡等待的未傳送郵件:{count} 封。它們將遺失。", + "confirm_keys": "S/MIME 和 OpenPGP 金鑰。要再次使用,需要您自己的備份。", + "confirm_plugins": "外掛程式儲存在這裡的資料", + "confirm_files": "為編輯而開啟的附件和等待附加的檔案", + "confirm_accounts": "所有帳號都將登出", + "confirm_button": "移除所有內容" + }, "offlineDrafts": { "title": "此裝置上的草稿", "description": "這些草稿儲存在此裝置上。一旦能連線到伺服器,就會自動上傳。", diff --git a/locales/zh/common.json b/locales/zh/common.json index de9e40f3c..734a373bf 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5763,6 +5763,23 @@ "store_private": "隐私窗口无法保留供离线使用的邮件。", "server_not_answering": "邮件服务器没有响应。请稍后再试。" }, + "offlineWipe": { + "note": "您的组织已从此设备移除此帐户的数据。请登录以重新开始。", + "note_unsent": "您的组织已从此设备移除此帐户的数据。已删除的未发送邮件:{count} 封。", + "settings_title": "从此设备移除所有内容", + "settings_description": "用于将这台电脑送人或不再使用时。服务器上的邮件会保留。", + "settings_button": "移除所有内容…", + "settings_wiping": "正在移除…", + "confirm_title": "要从此设备移除所有内容吗?", + "confirm_message": "这会删除此应用为这里所有帐户保存在此设备上的内容。服务器上的邮件不受影响。", + "confirm_mail": "保存在这里的邮件、日历、联系人和文件", + "confirm_unsent": "在这里等待的未发送邮件:{count} 封。它们将丢失。", + "confirm_keys": "S/MIME 和 OpenPGP 密钥。要再次使用,需要您自己的备份。", + "confirm_plugins": "插件保存在这里的数据", + "confirm_files": "为编辑而打开的附件和等待添加的文件", + "confirm_accounts": "所有帐户都将退出登录", + "confirm_button": "移除所有内容" + }, "offlineDrafts": { "title": "此设备上的草稿", "description": "这些草稿保存在此设备上。一旦能连接服务器,就会自动上传。", diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 9396192d3..088f3533a 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -100,6 +100,8 @@ export const LITE_API_STRING_ALLOWLIST = [ "/api/plugin-secrets", // stores/settings-store.ts - settingsSyncEnabled is pinned off. "/api/settings", + // lib/devices/client.ts - offline mode and its wipes aren't in Lite (no OfflineHost, IS_LITE guards in the auth store). + "/api/devices", // Stalwart's own account discovery (lib/auth/lite-oauth.ts), not a route of this app. "/api/discover", // OAuth / SSO / pairing - Lite discovers and redeems codes in the browser diff --git a/stores/__tests__/auth-store-device-wipe.test.ts b/stores/__tests__/auth-store-device-wipe.test.ts new file mode 100644 index 000000000..3c9746234 --- /dev/null +++ b/stores/__tests__/auth-store-device-wipe.test.ts @@ -0,0 +1,271 @@ +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Where the auth store meets wipes: the check at a start comes before any local +// data, a login the server ended fires the hook on every path, and a password +// it keeps refusing signs out and starts a wait instead of wiping. + +const clients = vi.hoisted(() => ({ + connect: (async () => {}) as (client: unknown) => Promise, + made: [] as Array<{ refused?: (ms: number) => void }>, +})); + +vi.mock('@/lib/jmap/client', () => { + class JMAPClient { + refused?: (ms: number) => void; + constructor(public serverUrl: string, public username: string) { clients.made.push(this); } + static withBearer(serverUrl: string, _token: string, username: string) { return new JMAPClient(serverUrl, username); } + static fromOfflineSession(serverUrl: string, username: string) { return new JMAPClient(serverUrl, username); } + async connect() { await clients.connect(this); } + getAuthHeader() { return 'Basic x'; } + onConnectionChange() {} + onRateLimit() {} + onCredentialsRefused(callback: (ms: number) => void) { this.refused = callback; } + getRateLimitRemainingMs() { return 0; } + disconnect() {} + getUsername() { return this.username; } + async getIdentities() { return []; } + supportsContacts() { return false; } + supportsPrincipals() { return false; } + supportsVacationResponse() { return false; } + supportsCalendars() { return false; } + supportsSieve() { return false; } + getSharedAccounts() { return []; } + getAccountInfo() { return undefined; } + } + class RateLimitError extends Error {} + class RequestTimeoutError extends Error {} + return { JMAPClient, RateLimitError, RequestTimeoutError, generateMessageId: () => '' }; +}); + +const order = vi.hoisted(() => [] as string[]); +const gate = vi.hoisted(() => ({ run: (async () => {}) as () => Promise })); +vi.mock('@/lib/offline/device-wipe', () => ({ + runDeviceGate: async () => { order.push('gate'); await gate.run(); order.push('gate done'); }, +})); +vi.mock('@/lib/offline/offline-mode', () => ({ + registerOfflineHooks: () => {}, + offlineSessionFor: async () => { order.push('local copy'); return {}; }, + markStartedOffline: () => {}, +})); + +const heard = vi.hoisted(() => [] as string[]); +vi.mock('@/lib/offline/credentials-rejected', () => ({ + noteCredentialsRejected: (account: string) => { heard.push(account); }, + onCredentialsRejected: () => () => {}, +})); + +import { readRefusedLogins, CREDENTIALS_REFUSED_CONFIRM_MS, markWipePending, clearWipePending } from '@/lib/devices/wipe-state'; + +// Fresh for each test: a sign-out leaves slots closing in the module for a while. +let useOfflineStore: typeof import('@/lib/offline/state').useOfflineStore; +let useAccountStore: typeof import('@/stores/account-store').useAccountStore; +let useAuthStore: typeof import('@/stores/auth-store').useAuthStore; + +const ALICE = 'alice@mail.example.test'; +const BOB = 'bob@mail.example.test'; + +function account(id: string, slot: number, authMode: 'basic' | 'oauth') { + return { + id, label: id, serverUrl: 'https://mail.example.test', username: id.split('@mail')[0], authMode, rememberMe: true, + displayName: id, email: id, lastLoginAt: 1, isConnected: false, hasError: false, isDefault: slot === 0, cookieSlot: slot, avatarColor: '#000', + }; +} + +type Answer = (url: string, method: string) => Response | null; +let answer: Answer; +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +beforeEach(async () => { + vi.resetModules(); + ({ useOfflineStore } = await import('@/lib/offline/state')); + ({ useAccountStore } = await import('@/stores/account-store')); + ({ useAuthStore } = await import('@/stores/auth-store')); + const nav = await import('@/lib/browser-navigation'); + localStorage.clear(); + sessionStorage.clear(); + order.length = 0; + heard.length = 0; + clients.made.length = 0; + clients.connect = async () => {}; + gate.run = async () => {}; + answer = () => null; + vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => { + const url = String(input); + const given = answer(url, init?.method ?? 'GET'); + if (given) return given; + if (url.includes('/api/auth/session') && init?.method === 'PUT') return json(200, { serverUrl: 'https://mail.example.test', username: 'alice', password: 'p' }); + if (url.includes('/api/auth/token') && init?.method === 'PUT') return json(200, { access_token: 't', expires_in: 3600 }); + return json(200, {}); + })); + useOfflineStore.setState({ enabled: [] }); + useAuthStore.setState({ isAuthenticated: false, activeAccountId: null, client: null }); + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null } as never); +}, 60_000); + +describe('a start', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + }); + + it('with mail kept here checks for a wipe before it reads the local copy', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + await useAuthStore.getState().checkAuth(); + expect(order.slice(0, 3)).toEqual(['gate', 'gate done', 'local copy']); + }); + + it('with no mail here and nothing waiting doesn\'t load any of it', async () => { + await useAuthStore.getState().checkAuth(); + expect(order).toEqual([]); + }); + + it('with a wipe cut short looks too, with offline switched off by then', async () => { + markWipePending(ALICE, 'admin'); + await useAuthStore.getState().checkAuth(); + clearWipePending(ALICE); + expect(order).toContain('gate'); + }); + + it('in the desktop app on an instance looks too', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + await useAuthStore.getState().checkAuth(); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + expect(order).toContain('gate'); + }); + + it('shows nothing of an account the check removed, and stops when none is left', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + gate.run = async () => { + useAccountStore.setState({ accounts: [], activeAccountId: null } as never); + }; + await useAuthStore.getState().checkAuth(); + expect(order).not.toContain('local copy'); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().isLoading).toBe(false); + expect(clients.made).toEqual([]); + }); + + it('goes on when the check itself breaks', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + gate.run = async () => { throw new Error('no'); }; + await useAuthStore.getState().checkAuth(); + expect(order).toContain('local copy'); + }); +}); + +describe('a login the server ended, at a start', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + }); + + it('invalid_grant fires the hook', async () => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([ALICE]); + }); + + it('the session limit fires the hook, for a password login too', async () => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')] } as never); + answer = (url, method) => (url.includes('/api/auth/session') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([ALICE]); + }); + + it.each([ + ['a refusal other than invalid_grant', () => json(401, { error: 'Refresh failed' })], + ['a 401 it can\'t read', () => new Response('nope', { status: 401 })], + ['a 500', () => json(500, {})], + ['a 503', () => json(503, {})], + ])('%s doesn\'t', async (_name, make) => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? make() : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([]); + }); + + it('a missing session cookie doesn\'t', async () => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')] } as never); + answer = (url, method) => (url.includes('/api/auth/session') && method === 'PUT' ? json(401, { error: 'No session' }) : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([]); + expect(readRefusedLogins()).toEqual({}); + }); +}); + +describe('a login the server ended, at a switch', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth'), account(BOB, 1, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE, isAuthenticated: true }); + }); + + it('invalid_grant fires the hook and takes the account out for good', async () => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); + await useAuthStore.getState().switchAccount(BOB); + expect(heard).toEqual([BOB]); + expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined(); + }); + + it('another refusal doesn\'t', async () => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'Refresh failed' }) : null); + await useAuthStore.getState().switchAccount(BOB); + expect(heard).toEqual([]); + }); +}); + +describe('a password the server keeps refusing', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + useOfflineStore.setState({ enabled: [ALICE] }); + }); + + it('at a start: the wait begins, and no wipe', async () => { + useOfflineStore.setState({ enabled: [] }); + clients.connect = async (client) => { + (client as { refused?: (ms: number) => void }).refused?.(0); + throw new Error('Invalid username or password'); + }; + await useAuthStore.getState().checkAuth(); + expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); + expect(heard).toEqual([]); + expect(useAccountStore.getState().accounts).toEqual([]); + }); + + it('at a start, with a server that only failed: no wait', async () => { + useOfflineStore.setState({ enabled: [] }); + clients.connect = async () => { throw new Error('Failed to get session: 500'); }; + await useAuthStore.getState().checkAuth(); + expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([]); + }); + + describe('while running', () => { + async function running(): Promise<{ refused: (ms: number) => void }> { + useOfflineStore.setState({ enabled: [] }); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(useAuthStore.getState().client).not.toBeNull()); + const client = useAuthStore.getState().client as unknown as { refused: (ms: number) => void }; + return client; + } + + it('one answer changes nothing', async () => { + const client = await running(); + client.refused(0); + client.refused(CREDENTIALS_REFUSED_CONFIRM_MS - 1); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(useAccountStore.getState().getAccountById(ALICE)).toBeDefined(); + expect(readRefusedLogins()).toEqual({}); + }); + + it('lasting a minute signs out and starts the wait, and fires no hook', async () => { + const client = await running(); + client.refused(CREDENTIALS_REFUSED_CONFIRM_MS); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); + expect(heard).toEqual([]); + expect(sessionStorage.getItem('session_expired')).toBe('true'); + }); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9fc0c7ca3..f88d6530b 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -37,6 +37,7 @@ import { withTokenLock } from '@/lib/auth/token-lock'; import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel'; import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role'; import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; +import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state'; import { readEvicted, useOfflineStore } from '@/lib/offline/state'; import { toAsciiEmail } from '@/lib/idn'; import { flushComposers } from '@/lib/compose-flush-registry'; @@ -790,12 +791,50 @@ export function pickEndSessionSlot(accounts: AccountEntry[], activeAccountId: st return target ? target.cookieSlot : null; } +/** Accounts the server refused during a connect that hasn't reported back yet. */ +const refusedAtConnect = new Set(); +const endingRefused = new Set(); + +/** + * The server kept refusing this login, after a fresh try each time. The account + * signs out and its data waits a day for a sign-in with the new password + * (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). It has no parsed + * refusal like `invalid_grant` to go by, so nothing goes at once. + */ +async function endRefusedLogin(accountId: string, get: () => AuthState): Promise { + const account = useAccountStore.getState().getAccountById(accountId); + if (!account || endingRefused.has(accountId)) return; + endingRefused.add(accountId); + try { + markLoginRefused(accountId); + if (get().activeAccountId !== accountId) { + get().removeAccount(accountId, 'evicted'); + return; + } + await saveDraftsBeforeExpiry(account.cookieSlot ?? 0); + notifyParent('sso:session-expired'); + markSessionExpired(); + await get().logout({ endProviderSession: false, keepLocalData: true }); + } finally { + endingRefused.delete(accountId); + } +} + function bindClientStatusHandlers( client: IJMAPClient, set: (state: Partial) => void, get: () => AuthState, accountId?: string, ): void { + client.onCredentialsRefused?.((refusedForMs) => { + // A pop-out is on another window's session. Lite keeps no data to wait. + if (!accountId || IS_LITE || isPopoutWindow()) return; + refusedAtConnect.add(accountId); + // Still connecting: the restore that started this decides, when it fails. + if (clients.get(accountId) !== client) return; + if (refusedForMs >= CREDENTIALS_REFUSED_CONFIRM_MS) void endRefusedLogin(accountId, get); + }); + client.onConnectionChange((connected) => { if (!accountId || get().activeAccountId === accountId) { set({ connectionLost: !connected }); @@ -2670,6 +2709,7 @@ export const useAuthStore = create()( let targetClient = clients.get(accountId); const wasConnected = !!targetClient; let targetRestoreRateLimited = false; + let targetLoginOver = false; if (!targetClient) { // Null out the client immediately so the page doesn't fire data-loading @@ -2686,6 +2726,7 @@ export const useAuthStore = create()( try { if (targetAccount.authMode === 'oauth') { const res = await fetchSlotAccessToken(targetAccount.cookieSlot, { remember: targetAccount.rememberMe }); + if (!res.ok && res.status === 401) targetLoginOver = await loginReallyOver(res); if (res.ok) { const { access_token, expires_in } = await res.json(); targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => get().refreshAccessToken({ accountId })); @@ -2702,6 +2743,7 @@ export const useAuthStore = create()( } } else if (targetAccount.rememberMe) { const res = await fetchSlotSession(targetAccount.cookieSlot); + if (!res.ok && res.status === 401) targetLoginOver = await loginReallyOver(res); if (res.ok) { const session: RememberedSession = await res.json(); targetClient = clientForSession(session); @@ -2753,8 +2795,11 @@ export const useAuthStore = create()( } // Cannot restore - remove the stale account and redirect to login + if (targetLoginOver) noteCredentialsRejected(accountId); + else if (refusedAtConnect.has(accountId)) markLoginRefused(accountId); + refusedAtConnect.delete(accountId); evictAccount(accountId); - accountStore.removeAccount(accountId, 'evicted'); + accountStore.removeAccount(accountId, targetLoginOver ? 'explicit' : 'evicted'); void clearSlotCredentials(targetAccount.cookieSlot, targetAccount.authMode === 'oauth'); // Restore the previous account if still available @@ -2938,6 +2983,17 @@ export const useAuthStore = create()( await import('@/lib/offline/offline-mode').then((offline) => offline.registerOfflineHooks()).catch(() => {}); } + // A wipe comes before anything from this device is shown: one a closed + // window cut short, and what the instance has to say, briefly. + if (!IS_LITE && (useOfflineStore.getState().enabled.length > 0 || getDesktopBridge()?.mode === 'instance' || wipeWorkWaiting())) { + await import('@/lib/offline/device-wipe').then((wipe) => wipe.runDeviceGate()).catch(() => {}); + accounts = useAccountStore.getState().accounts; + if (accounts.length === 0) { + set({ isLoading: false, isAuthenticated: false, client: null, activeAccountId: null }); + return; + } + } + // Determine which account to activate first const defaultAccount = accountStore.getDefaultAccount(); const activeId = get().activeAccountId; @@ -3013,7 +3069,7 @@ export const useAuthStore = create()( } else if (res.status >= 500 || res.status === 429) { throw new TransientAuthError('Session restore failed', res.status); } else { - throw new Error(`Session cookie missing: ${res.status}`); + throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { loginOver: await loginReallyOver(res) }); } } } catch (err) { @@ -3051,8 +3107,12 @@ export const useAuthStore = create()( // again rather than seeing a stale error entry forever. Its // refresh token goes with it: nothing would use or revoke it. Its // local data waits, unless the server really ended the login. + const loginOver = (err as { loginOver?: boolean }).loginOver === true; + if (loginOver) noteCredentialsRejected(account.id); + else if (refusedAtConnect.has(account.id)) markLoginRefused(account.id); + refusedAtConnect.delete(account.id); evictAccount(account.id); - accountStore.removeAccount(account.id, (err as { loginOver?: boolean }).loginOver ? 'explicit' : 'evicted'); + accountStore.removeAccount(account.id, loginOver ? 'explicit' : 'evicted'); void clearSlotCredentials(account.cookieSlot, account.authMode === 'oauth'); } }; From 9c36801f5e4a717c300af52cc545be87f321b0ff Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:37:15 +0100 Subject: [PATCH 20/45] fix: keep composers mounted while the app lock saves them, and tell the service worker nothing with the lock off --- .../popout/__tests__/popout-host.test.tsx | 25 ++++++++++++------ lib/app-lock/__tests__/controller.test.ts | 26 +++++++++++++++---- lib/app-lock/controller.ts | 3 +-- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index 239f3045c..dcdb2e036 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -122,28 +122,37 @@ describe('PopoutHost', () => { expect(JSON.stringify(heard)).not.toContain('Bearer main'); }); - it('gives no credentials while the app is locked, also to a pop-out that never heard of the lock', async () => { + it('gives no credentials while the app is locked, to a pop-out that never heard of the lock', async () => { render(); await settle(); setLockedHere(true); - const channel = openPopoutChannel(() => {}); const opener = await createSessionOpener(); - channel?.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey }); + popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey }); await settle(); - channel?.close(); expect(answers()).toEqual([]); - await expect(requestWindowSession({}, 100)).resolves.toBeNull(); setLockedHere(false); - await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({ activeAccountId: 'alice' }); + popout.post({ type: 'session-request', requestId: 'r2', publicKey: opener.publicKey }); + await vi.waitFor(() => expect(answers()).toHaveLength(1)); }); - it('a locked pop-out takes no session from the cookies either', async () => { + it('a locked pop-out doesn\'t ask for a session', async () => { + setLockedHere(true); + await expect(requestWindowSession({}, 100)).resolves.toBeNull(); + setLockedHere(false); + expect(heard.filter((message) => message.type === 'session-request')).toEqual([]); + }); + + it('a locked pop-out takes no session from the cookies either, and asks nobody', async () => { + const fetched = vi.fn(async () => new Response('{}')); + vi.stubGlobal('fetch', fetched); setLockedHere(true); try { - await expect(useAuthStore.getState().startSharedSession({})).resolves.toBe('no-session'); + await expect(useAuthStore.getState().startSharedSession({ accountId: 'alice' })).resolves.toBe('no-session'); } finally { setLockedHere(false); } + expect(fetched).not.toHaveBeenCalled(); + expect(useAuthStore.getState().client).not.toBeNull(); }); it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => { diff --git a/lib/app-lock/__tests__/controller.test.ts b/lib/app-lock/__tests__/controller.test.ts index 1c6a03da4..958875a38 100644 --- a/lib/app-lock/__tests__/controller.test.ts +++ b/lib/app-lock/__tests__/controller.test.ts @@ -197,6 +197,14 @@ describe('while locked', () => { expect(isAppLockedNow()).toBe(true); }); + it('a running page isn\'t held back while it saves: composers stay mounted until the reload', async () => { + await enableAppLock(PIN); + let holdDuringSave: Promise | null | undefined; + registerComposeFlush(async () => { holdDuringSave = appLockHold(); }); + await lockedAndReloaded(); + expect(holdDuringSave).toBeNull(); + }); + it('notifications go by it: the check the OS code asks says locked', async () => { await enableAppLock(PIN); asNewDocument(); @@ -286,15 +294,23 @@ describe('locking', () => { }); describe('the store when a tab inherits it without the key', () => { - it('locks everywhere instead of opening or writing an unwrapped key', async () => { + it('asks for the lock instead of opening, or writing a key outside the layer', async () => { await enableAppLock(PIN); + // This tab has no key: the one that had it went. resetAppLockForTests(); + (window.navigator.locks.query as ReturnType).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] }); + (window.navigator.locks.request as ReturnType).mockImplementation(() => new Promise(() => {})); bootAppLock(); - await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); - const layers = () => appLockKeyLayers(); - expect(layers).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); - // A new account can't get a key record outside the layer either. + await vi.waitFor(() => expect(getAppLockPhase()).toBe('open')); + const onKeyLocked = vi.fn(); + configureStore({ ...hooks, onKeyLocked }); + await expect(getStore().ensureOpen('new-account', { apiUrl: 'x' } as never).then(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + await getStore().ensureOpen('new-account'); + })).rejects.toMatchObject({ name: 'StoreKeyLayerMissingError' }); + expect(onKeyLocked).toHaveBeenCalled(); expect(await getAccountRecord('new-account')).toBeNull(); + expect(lastWorker().posted.some((m) => m.kind === 'open')).toBe(false); }); it('a new account after unlock gets its key under the lock', async () => { diff --git a/lib/app-lock/controller.ts b/lib/app-lock/controller.ts index 46c71533c..cf1b5fb69 100644 --- a/lib/app-lock/controller.ts +++ b/lib/app-lock/controller.ts @@ -366,7 +366,6 @@ async function applyLockHere(): Promise { const wasRunning = phase === 'open'; setLockedHere(true); markDocument(true); - hold ??= newHold(); setPhase('locking'); tellDesktopLocked(); tellServiceWorker('locked'); @@ -699,7 +698,7 @@ export function startAppLock(): () => void { startIdle(); if (!heldKey) watchHolder(); } - tellServiceWorker(readAppLockFile().on ? (isAppLockedNow() ? 'locked' : 'unlocked') : 'off'); + if (readAppLockFile().on) tellServiceWorker(isAppLockedNow() ? 'locked' : 'unlocked'); return () => { channel?.close(); window.removeEventListener('storage', onStorage); From ae6450e84de61ec8f5005959a58def04f536cace Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:47:13 +0100 Subject: [PATCH 21/45] fix: write the desktop app's English menus in sentence case like the rest of the app --- locales/en/common.json | 50 +++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/locales/en/common.json b/locales/en/common.json index 6112c3ddd..361962e6e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6567,10 +6567,10 @@ "go": "Go", "window": "Window", "help": "Help", - "new_window": "New Window", + "new_window": "New window", "print": "Print…", "save_pdf": "Save as PDF…", - "switch_connection": "Change Connection…", + "switch_connection": "Change connection…", "close": "Close", "quit": "Quit", "undo": "Undo", @@ -6578,28 +6578,28 @@ "cut": "Cut", "copy": "Copy", "paste": "Paste", - "select_all": "Select All", - "search": "Search Mail", - "refresh": "Check for New Mail", - "reading_pane": "Reading Pane", - "pane_right": "On the Right", - "pane_below": "Below the List", + "select_all": "Select all", + "search": "Search mail", + "refresh": "Check for new mail", + "reading_pane": "Reading pane", + "pane_right": "On the right", + "pane_below": "Below the list", "pane_off": "Off", - "zoom_in": "Zoom In", - "zoom_out": "Zoom Out", - "zoom_reset": "Actual Size", - "full_screen": "Full Screen", - "open_in_window": "Open in New Window", - "next_message": "Next Message", - "previous_message": "Previous Message", - "next_unread": "Next Unread Message", + "zoom_in": "Zoom in", + "zoom_out": "Zoom out", + "zoom_reset": "Actual size", + "full_screen": "Full screen", + "open_in_window": "Open in new window", + "next_message": "Next message", + "previous_message": "Previous message", + "next_unread": "Next unread message", "mail": "Mail", "files": "Files", "back": "Back", "forward": "Forward", "minimize": "Minimize", - "next_window": "Next Window", - "shortcuts": "Keyboard Shortcuts", + "next_window": "Next window", + "shortcuts": "Keyboard shortcuts", "docs": "Documentation", "settings": "Settings…", "about": "About Bulwark", @@ -6607,13 +6607,13 @@ "pick_gone": "That menu was out of date. Open it again and try once more." }, "context": { - "add_to_dictionary": "Add \"{word}\" to Dictionary", - "no_suggestions": "No Suggestions", - "open_link": "Open Link", - "copy_link": "Copy Link", - "copy_address": "Copy Address", - "save_image": "Save Image…", - "copy_image": "Copy Image" + "add_to_dictionary": "Add \"{word}\" to dictionary", + "no_suggestions": "No suggestions", + "open_link": "Open link", + "copy_link": "Copy link", + "copy_address": "Copy address", + "save_image": "Save image…", + "copy_image": "Copy image" }, "closing": { "unsaved_title": "A draft could not be saved", From 7173178ded137a9054ebe829290e59d72d28d0b9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:04:32 +0100 Subject: [PATCH 22/45] fix: name the product on the lock screen, not the default app name --- components/app-lock/lock-screen.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/components/app-lock/lock-screen.tsx b/components/app-lock/lock-screen.tsx index 2dc827000..20f37bc3d 100644 --- a/components/app-lock/lock-screen.tsx +++ b/components/app-lock/lock-screen.tsx @@ -16,6 +16,7 @@ import { pinBlockedUntil, readVault } from "@/lib/app-lock/vault"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import type { DesktopOsUnlockKind } from "@/lib/desktop/types"; import { isPopoutWindow } from "@/lib/popout/window-role"; +import { productName } from "@/lib/product-name"; /** How long a pop-out waits to hear from a main window before it asks for the PIN itself. */ const MAIN_ANSWER_MS = 500; @@ -86,7 +87,7 @@ function Frame({ title, body, children }: { title: string; body?: string; childr function SetupForm() { const t = useTranslations("appLock"); - const { appName } = useConfig(); + const appName = productName(useConfig().appName); const [pin, setPin] = useState(""); const [repeat, setRepeat] = useState(""); const [busy, setBusy] = useState(false); @@ -116,7 +117,7 @@ function SetupForm() { function UnlockForm() { const t = useTranslations("appLock"); - const { appName } = useConfig(); + const appName = productName(useConfig().appName); const { dialogProps, confirm } = useConfirmDialog(); const [pin, setPin] = useState(""); const [busy, setBusy] = useState(false); @@ -252,7 +253,7 @@ function UnlockForm() { /** What covers a window while the app is locked. Reads nothing but the lock's own records. */ export function LockScreen({ phase }: { phase: AppLockPhase }) { const t = useTranslations("appLock"); - const { appName } = useConfig(); + const appName = productName(useConfig().appName); if (phase === "setup") return ; if (phase === "locking") { return ( From d1227bfac6402a31f8bf8a50898066c90b43d9f2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:11:44 +0100 Subject: [PATCH 23/45] fix: log a device wipe only when its row changes, and keep rows from piling up --- app/api/admin/devices/__tests__/auth.test.ts | 91 ++++++++++++++ app/api/devices/__tests__/route.test.ts | 123 ++++++++++++++++++- app/api/devices/route.ts | 19 ++- app/api/devices/wiped/route.ts | 33 +++-- lib/devices/__tests__/registry.test.ts | 58 +++++++++ lib/devices/registry.ts | 82 ++++++++++--- 6 files changed, 378 insertions(+), 28 deletions(-) create mode 100644 app/api/admin/devices/__tests__/auth.test.ts diff --git a/app/api/admin/devices/__tests__/auth.test.ts b/app/api/admin/devices/__tests__/auth.test.ts new file mode 100644 index 000000000..77cb78375 --- /dev/null +++ b/app/api/admin/devices/__tests__/auth.test.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The real admin check, not a stand-in: a wipe request from another site carries +// the admin's cookie, and only the same-origin check stands in its way. + +const jar = vi.hoisted(() => new Map()); +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: (name: string) => (jar.has(name) ? { name, value: jar.get(name) } : undefined), + set: (name: string, value: string) => { jar.set(name, value); }, + delete: (name: string) => { jar.delete(name); }, + }), +})); + +let dir: string; + +function post(body: unknown, headers: Record): NextRequest { + return new NextRequest('https://mail.example.org/api/admin/devices', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +function audit(): string { + try { + return readFileSync(path.join(dir, 'audit.log'), 'utf8'); + } catch { + return ''; + } +} + +describe('/api/admin/devices with the real admin check', () => { + let route: typeof import('../route'); + let registry: typeof import('@/lib/devices/registry'); + let row: { id: string }; + + beforeEach(async () => { + vi.resetModules(); + dir = mkdtempSync(path.join(tmpdir(), 'bw-device-admin-')); + vi.stubEnv('ADMIN_STATE_DIR', dir); + vi.stubEnv('ADMIN_CONFIG_DIR', dir); + vi.stubEnv('SESSION_SECRET', 'x'.repeat(64)); + jar.clear(); + route = await import('../route'); + registry = await import('@/lib/devices/registry'); + row = await registry.registerDevice('alice@example.org', 'https://mail.example.org', { platform: 'x', appVersion: '1' }); + const session = await import('@/lib/admin/session'); + const { getAdminSessionGeneration } = await import('@/lib/admin/password'); + jar.set('admin_session', session.createAdminSession(await getAdminSessionGeneration())); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); + }); + + it.each([ + ['another site says so', { 'sec-fetch-site': 'cross-site' }], + ['a page on another origin', { origin: 'https://evil.example.net' }], + ])('refuses a wipe with the admin\'s own cookie when %s', async (_name, headers) => { + const response = await route.POST(post({ action: 'wipe', id: row.id }, headers)); + expect(response.status).toBe(403); + expect((await registry.getDevice(row.id))?.wipe).toBeNull(); + expect(audit()).toBe(''); + }); + + it('refuses wiping a whole user from another site', async () => { + const response = await route.POST(post({ action: 'wipe-user', userKey: row.id.split('/')[0] }, { 'sec-fetch-site': 'cross-site' })); + expect(response.status).toBe(403); + expect((await registry.getDevice(row.id))?.wipe).toBeNull(); + }); + + it('takes it from the same origin with the cookie', async () => { + const response = await route.POST(post({ action: 'wipe', id: row.id }, { 'sec-fetch-site': 'same-origin' })); + expect(response.status).toBe(200); + expect((await registry.getDevice(row.id))?.wipe).toMatchObject({ by: 'admin' }); + expect(audit()).toContain('device.wipe.request'); + }); + + it('refuses without the cookie, same origin or not', async () => { + jar.clear(); + const response = await route.POST(post({ action: 'wipe', id: row.id }, { 'sec-fetch-site': 'same-origin' })); + expect(response.status).toBe(401); + expect((await registry.getDevice(row.id))?.wipe).toBeNull(); + }); +}); diff --git a/app/api/devices/__tests__/route.test.ts b/app/api/devices/__tests__/route.test.ts index 58e141401..3d26acf90 100644 --- a/app/api/devices/__tests__/route.test.ts +++ b/app/api/devices/__tests__/route.test.ts @@ -8,15 +8,17 @@ const creds = vi.hoisted(() => ({ value: null as null | { accountName: string; s vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => creds.value })); const admin = vi.hoisted(() => ({ allowed: true })); +// Rate limits are per address and outlive a test: each test is its own address. +const client = vi.hoisted(() => ({ ip: '', count: 0 })); vi.mock('@/lib/admin/session', async (original) => ({ ...await original(), requireAdminAuth: async () => (admin.allowed ? { session: { role: 'admin' } } : { error: new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) }), - getClientIP: () => '203.0.113.9', + getClientIP: () => client.ip, })); -import { POST as checkIn } from '../route'; +import { POST as checkIn, REGISTER_PER_HOUR } 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'; @@ -49,7 +51,11 @@ async function register(): Promise<{ id: string; secret: string }> { return body; } +vi.setConfig({ testTimeout: 60_000 }); + beforeEach(() => { + client.count += 1; + client.ip = `203.0.113.${client.count}`; dir = mkdtempSync(path.join(tmpdir(), 'bw-device-routes-')); vi.stubEnv('ADMIN_STATE_DIR', dir); admin.allowed = true; @@ -97,6 +103,45 @@ describe('POST /api/devices', () => { expect(await wrong.json()).toEqual(await missing.json()); }); + it('tells the device whose row was wiped, so it can start over', async () => { + const { id, secret } = await register(); + await registry.requestDeviceWipe(id, 'admin'); + await registry.confirmDeviceWipe(id, secret, 'admin'); + const answer = await checkIn(request('/api/devices', { id, secret, platform: 'x', appVersion: '1' })); + expect(await answer.json()).toEqual({ wipe: null, wiped: true }); + }); + + it('gives a new row\'s owner with the row, so the device can check it', async () => { + creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' }; + const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' })); + expect(await response.json()).toMatchObject({ user: 'alice@example.org', server: 'https://mail.example.org' }); + }); + + 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) { + 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.at(-1)).toBe(429); + expect((await registry.listDevices()).total).toBe(REGISTER_PER_HOUR); + + // A device that's already registered still hears about its wipe. + const [{ id }] = (await registry.listDevices()).devices; + await registry.requestDeviceWipe(id, 'admin'); + const answer = await checkIn(request('/api/devices', { id, secret: 'f'.repeat(64) })); + expect(answer.status).toBe(404); + }); + + it('turns down a new row past the cap when every row has a wipe waiting', async () => { + creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' }; + const spy = vi.spyOn(registry, 'registerDevice').mockRejectedValueOnce(new registry.TooManyDevicesError()); + const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' })); + spy.mockRestore(); + expect(response.status).toBe(429); + }); + it('won\'t store a platform with control characters or at any length', async () => { creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' }; const response = await checkIn(request('/api/devices', { platform: `a\nb${'x'.repeat(200)}`, appVersion: '1' })); @@ -120,7 +165,79 @@ describe('POST /api/devices/wiped', () => { expect(device?.wipedAt).not.toBeNull(); const entry = audit().find((line) => line.action === 'device.wipe.confirm'); expect(entry?.detail).toMatchObject({ device: id, user: 'alice@example.org', reason: 'admin', unsentDeleted: 2 }); - expect(entry?.ip).toBe('203.0.113.9'); + expect(entry?.ip).toBe(client.ip); + }); + + it('sent again, writes no second entry: a row that didn\'t change leaves no trace', async () => { + const { id, secret } = await register(); + await registry.requestDeviceWipe(id, 'admin'); + for (let i = 0; i < 3; i += 1) { + expect((await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 2 }))).status).toBe(200); + } + expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1); + }); + + it('won\'t let any signed-in user fill the log: repeating a user wipe writes one entry', async () => { + const { id, secret } = await register(); + for (let i = 0; i < 3; i += 1) await wiped(request('/api/devices/wiped', { id, secret, reason: 'user', unsent: 0 })); + expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1); + }); + + it('refuses reason admin when no wipe was asked, changes nothing and writes nothing', async () => { + const { id, secret } = await register(); + const response = await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 0 })); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: 'no_wipe_asked' }); + expect((await registry.getDevice(id))?.wipedAt).toBeNull(); + expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toEqual([]); + }); + + it('turns down a reason it doesn\'t know instead of calling it the admin\'s', async () => { + const { id, secret } = await register(); + await registry.requestDeviceWipe(id, 'admin'); + expect((await wiped(request('/api/devices/wiped', { id, secret, reason: 'mischief' }))).status).toBe(400); + expect((await registry.getDevice(id))?.wipe).not.toBeNull(); + }); + + it('records an unknown count as unknown, not as none', async () => { + const { id, secret } = await register(); + await registry.requestDeviceWipe(id, 'admin'); + await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: null })); + expect(audit().find((line) => line.action === 'device.wipe.confirm')?.detail).toMatchObject({ unsentDeleted: 'unknown' }); + }); + + it('a sign-out drops the row with its secret, and writes nothing', async () => { + const { id, secret } = await register(); + expect((await wiped(request('/api/devices/wiped', { id, secret, drop: true }))).status).toBe(200); + expect(await registry.getDevice(id)).toBeNull(); + expect(audit()).toEqual([]); + }); + + it('a sign-out with a wipe waiting answers that wipe: the row stays, confirmed and on the record', async () => { + const { id, secret } = await register(); + await registry.requestDeviceWipe(id, 'admin'); + expect((await wiped(request('/api/devices/wiped', { id, secret, drop: true }))).status).toBe(200); + const device = await registry.getDevice(id); + expect(device?.wipe).toBeNull(); + expect(device?.wipedAt).not.toBeNull(); + expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1); + }); + + it('won\'t drop a row without the right secret', async () => { + const { id } = await register(); + expect((await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), drop: true }))).status).toBe(404); + expect(await registry.getDevice(id)).not.toBeNull(); + }); + + it('answers 429 past the limit and takes nothing', async () => { + const { id } = await register(); + await registry.requestDeviceWipe(id, 'admin'); + let last = 200; + for (let i = 0; i <= 300 && last !== 429; i += 1) { + last = (await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), reason: 'admin' }))).status; + } + expect(last).toBe(429); + expect((await registry.getDevice(id))?.wipe).not.toBeNull(); }); it('takes nothing without the right secret and writes no entry', async () => { diff --git a/app/api/devices/route.ts b/app/api/devices/route.ts index eea279c95..40748e1fe 100644 --- a/app/api/devices/route.ts +++ b/app/api/devices/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getClientIP } from '@/lib/admin/session'; -import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, registerDevice } from '@/lib/devices/registry'; +import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, 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,6 +11,12 @@ 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 ''; @@ -54,14 +60,23 @@ export async function POST(request: NextRequest) { } const device = await checkInDevice(body.id, body.secret, info); if (!device) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE }); - return NextResponse.json({ wipe: device.wipe ? { requestedAt: device.wipe.requestedAt } : null }, { headers: NO_STORE }); + if (device.wipe) return NextResponse.json({ wipe: { requestedAt: device.wipe.requestedAt } }, { headers: NO_STORE }); + // Its data went already. A device still holding this row starts over. + return NextResponse.json(device.wipedAt ? { wipe: null, wiped: true } : { wipe: null }, { headers: NO_STORE }); } const creds = await getStalwartCredentials(request); if (!creds) return NextResponse.json({ error: 'Not authenticated' }, { status: 401, headers: NO_STORE }); + const newRows = await getStore().counters.reserve([ + { key: `device-register:${getClientIP(request)}`, max: REGISTER_PER_HOUR, windowMs: 60 * 60 * 1000 }, + ]).catch(() => ({ ok: true })); + if (!newRows.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE }); const created = await registerDevice(creds.accountName, creds.serverUrl, info); return NextResponse.json({ ...created, wipe: null }, { headers: NO_STORE }); } catch (error) { + if (error instanceof Error && error.name === new TooManyDevicesError().name) { + return NextResponse.json({ error: 'too_many_devices' }, { status: 429, headers: NO_STORE }); + } if (isStoreUnavailable(error)) { return NextResponse.json({ error: 'Temporarily unavailable' }, { status: 503, headers: { ...NO_STORE, 'Retry-After': '30' } }); } diff --git a/app/api/devices/wiped/route.ts b/app/api/devices/wiped/route.ts index a8258108d..31c10e2b3 100644 --- a/app/api/devices/wiped/route.ts +++ b/app/api/devices/wiped/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { auditLog } from '@/lib/admin/audit'; import { getClientIP } from '@/lib/admin/session'; -import { confirmDeviceWipe, DEVICE_ID_RE, DEVICE_SECRET_RE } from '@/lib/devices/registry'; +import { confirmDeviceWipe, DEVICE_ID_RE, DEVICE_SECRET_RE, dropDevice } from '@/lib/devices/registry'; +import type { DeviceView } from '@/lib/devices/types'; import { AUDIT_DEVICE_WIPE_CONFIRM, WIPE_REASONS, type WipeReason } from '@/lib/devices/types'; import { logger } from '@/lib/logger'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; @@ -16,7 +17,10 @@ const PER_MINUTE = 300; /** * POST /api/devices/wiped: a device says its wipe is done. By then it has signed * out or is about to, so its secret is the proof, not a session. Sent again at - * the next contact until it gets through, so it answers the same each time. + * the next contact until it gets through, so it answers the same each time, and + * only a row that really changes goes in the log. + * + * With `drop`: the account signed out here, with its data, so the row goes too. */ export async function POST(request: NextRequest) { const crossOrigin = rejectCrossOriginRequest(request); @@ -39,19 +43,30 @@ export async function POST(request: NextRequest) { if (typeof body?.id !== 'string' || !DEVICE_ID_RE.test(body.id) || typeof body.secret !== 'string' || !DEVICE_SECRET_RE.test(body.secret)) { return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE }); } - const reason: WipeReason = WIPE_REASONS.includes(body.reason as WipeReason) ? body.reason as WipeReason : 'admin'; - const unsent = typeof body.unsent === 'number' && Number.isFinite(body.unsent) && body.unsent >= 0 ? Math.floor(body.unsent) : 0; - - const device = await confirmDeviceWipe(body.id, body.secret, reason); - if (!device) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE }); - await auditLog(AUDIT_DEVICE_WIPE_CONFIRM, { + const write = (device: DeviceView, reason: WipeReason, unsentDeleted: number | 'unknown') => auditLog(AUDIT_DEVICE_WIPE_CONFIRM, { device: device.id, user: device.user, server: device.server, platform: device.platform, reason, - unsentDeleted: unsent, + unsentDeleted, }, getClientIP(request)); + + if (body.drop === true) { + const dropped = await dropDevice(body.id, body.secret); + if (!dropped) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE }); + if (dropped.confirmed) await write(dropped.confirmed, 'user', 'unknown'); + return NextResponse.json({ ok: true }, { headers: NO_STORE }); + } + + if (!WIPE_REASONS.includes(body.reason as WipeReason)) return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE }); + const reason = body.reason as WipeReason; + const unsent = typeof body.unsent === 'number' && Number.isFinite(body.unsent) && body.unsent >= 0 ? Math.floor(body.unsent) : 'unknown'; + + const answer = await confirmDeviceWipe(body.id, body.secret, reason); + if (!answer) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE }); + if (answer === 'no_wipe_asked') return NextResponse.json({ error: 'no_wipe_asked' }, { status: 409, headers: NO_STORE }); + if (answer.changed) await write(answer.device, reason, unsent); return NextResponse.json({ ok: true }, { headers: NO_STORE }); } catch (error) { if (isStoreUnavailable(error)) { diff --git a/lib/devices/__tests__/registry.test.ts b/lib/devices/__tests__/registry.test.ts index 5c2afe014..88d2b92ae 100644 --- a/lib/devices/__tests__/registry.test.ts +++ b/lib/devices/__tests__/registry.test.ts @@ -7,6 +7,8 @@ import { setStoreForTests } from '@/lib/shared-store'; type Registry = typeof import('../registry'); +vi.setConfig({ testTimeout: 60_000 }); + let dir: string; const info = { platform: 'Chrome, Windows', appVersion: '1.2.3' }; @@ -166,6 +168,62 @@ describe('the rules', () => { expect(ids).not.toContain(next.id); }); + it('tells a confirmation that changed the row from one that didn\'t', async () => { + const registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + const first = await registry.confirmDeviceWipe(id, secret, 'admin'); + expect(first).toMatchObject({ changed: true }); + const again = await registry.confirmDeviceWipe(id, secret, 'admin'); + expect(again).toMatchObject({ changed: false }); + }); + + it('won\'t take a confirmation from the admin without a request', async () => { + const registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + expect(await registry.confirmDeviceWipe(id, secret, 'admin')).toBe('no_wipe_asked'); + expect((await registry.getDevice(id))?.wipedAt).toBeNull(); + }); + + it('prunes a user\'s abandoned rows when a new one registers, not ones with a wipe waiting', async () => { + const registry = await import('../registry'); + const start = Date.now() + 365 * 24 * 60 * 60 * 1000; + vi.useFakeTimers({ now: start, toFake: ['Date'] }); + const old = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const waiting = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(waiting.id, 'admin'); + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS + 60_000); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + vi.useRealTimers(); + expect(await registry.getDevice(old.id)).toBeNull(); + expect(await registry.getDevice(waiting.id)).not.toBeNull(); + }); + + it('abandoned rows expire with no admin looking, others\' too, through any registration', async () => { + const registry = await import('../registry'); + const start = Date.now() + 365 * 24 * 60 * 60 * 1000; + vi.useFakeTimers({ now: start, toFake: ['Date'] }); + const abandoned = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + const waiting = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(waiting.id, 'admin'); + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS + 2 * 60 * 60 * 1000); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + vi.useRealTimers(); + expect(await registry.getDevice(abandoned.id)).toBeNull(); + expect(await registry.getDevice(waiting.id)).not.toBeNull(); + }); + + it('keeps the cap with every row waiting for a wipe: a new row is refused, the wipes stay', async () => { + const registry = await import('../registry'); + const first = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const userKey = first.id.split('/')[0]; + for (let i = 1; i < registry.MAX_DEVICES_PER_USER; i += 1) await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + expect(await registry.requestUserWipe(userKey, 'admin')).toHaveLength(registry.MAX_DEVICES_PER_USER); + await expect(registry.registerDevice('alice@example.org', 'https://mail.example.org', info)).rejects.toThrow(registry.TooManyDevicesError); + expect((await registry.listDevices({ limit: 200 })).total).toBe(registry.MAX_DEVICES_PER_USER); + expect((await registry.getDevice(first.id))?.wipe).not.toBeNull(); + }); + it('finds a user by part of the address', async () => { const registry: Registry = await import('../registry'); await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); diff --git a/lib/devices/registry.ts b/lib/devices/registry.ts index b7cea30b9..c69d95813 100644 --- a/lib/devices/registry.ts +++ b/lib/devices/registry.ts @@ -4,7 +4,8 @@ * replica sees the same list and a pending wipe outlives a restart. * * No `ttlMs`: a machine that stays off for a year still has its wipe waiting. - * Old documents without a pending wipe go through `pruneDevices`. + * Rows nobody has seen for half a year and with no wipe waiting go when a new + * row registers (at most hourly for the whole registry) or an admin lists them. * * A check-in proves itself with the device's secret, not with a session, and * nothing here asks the mail server. A device whose password was revoked has @@ -25,11 +26,12 @@ const DEVICE_FILE_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}\.json$/; /** A user with more rows than this loses the oldest one that has no wipe waiting. */ export const MAX_DEVICES_PER_USER = 100; -/** Not seen for this long and no wipe waiting: forgotten by the next admin listing. */ +/** Not seen for this long and no wipe waiting: abandoned, and forgotten. */ export const DEVICE_PRUNE_MS = 180 * 24 * 60 * 60 * 1000; /** A check-in within this time of the last write doesn't write again. */ const LAST_SEEN_WRITE_MS = 5 * 60 * 1000; const READ_CONCURRENCY = 50; +const PRUNE_EVERY_MS = 60 * 60 * 1000; export const DEVICES_COLLECTION: CollectionOptions = { local: { @@ -88,13 +90,29 @@ async function readMany(ids: string[]): Promise> { return found; } -async function makeRoomFor(userKey: string): Promise { - const ids = await collection().list(`${userKey}/`); - if (ids.length < MAX_DEVICES_PER_USER) return; - const removable = (await readMany(ids)) +/** Every row of this user has a wipe waiting, which can't be dropped, and the cap is reached. */ +export class TooManyDevicesError extends Error { + constructor() { + super('Too many devices'); + this.name = 'TooManyDevicesError'; + } +} + +async function makeRoomFor(userKey: string, now: number): Promise { + const rows = await pruned(await readMany(await collection().list(`${userKey}/`)), now); + const over = rows.length - MAX_DEVICES_PER_USER + 1; + if (over <= 0) return; + const removable = rows .filter(([, device]) => !device.wipe) .sort(([, a], [, b]) => a.lastSeen.localeCompare(b.lastSeen)); - for (const [id] of removable.slice(0, ids.length - MAX_DEVICES_PER_USER + 1)) await collection().delete(id); + if (removable.length < over) throw new TooManyDevicesError(); + for (const [id] of removable.slice(0, over)) await collection().delete(id); +} + +/** Abandoned rows of everyone, however long no admin has looked. One replica, once an hour. */ +async function pruneAbandoned(now: number): Promise { + const { ok } = await getStore().counters.reserve([{ key: 'device-prune', max: 1, windowMs: PRUNE_EVERY_MS }]); + if (ok) await pruned(await readMany(await collection().list('')), now); } export interface DeviceInfo { @@ -103,9 +121,10 @@ export interface DeviceInfo { } /** A new row for this browser profile and account. The secret goes back to the device once and is kept only as a hash. */ -export async function registerDevice(user: string, server: string, info: DeviceInfo): Promise<{ id: string; secret: string }> { +export async function registerDevice(user: string, server: string, info: DeviceInfo): Promise<{ id: string; secret: string; user: string; server: string }> { const userKey = settingsId(user, server); - await makeRoomFor(userKey); + await makeRoomFor(userKey, Date.now()); + await pruneAbandoned(Date.now()).catch(() => {}); const id = `${userKey}/${randomBytes(16).toString('hex')}`; const secret = randomBytes(32).toString('hex'); const now = new Date().toISOString(); @@ -122,7 +141,7 @@ export async function registerDevice(user: string, server: string, info: DeviceI secretHash: hashSecret(secret), }; await collection().put(id, JSON.stringify(device), { ifVersion: null }); - return { id, secret }; + return { id, secret, user, server }; } /** Null when the row is gone or the secret doesn't fit. Both read the same to the caller. */ @@ -140,18 +159,52 @@ export async function checkInDevice(id: string, secret: string, info: DeviceInfo return device ? toView(id, device) : toView(id, current); } -/** The device says it wiped. Null when the row is gone or the secret doesn't fit. */ -export async function confirmDeviceWipe(id: string, secret: string, reason: WipeReason, now = Date.now()): Promise { +export type WipeConfirmation = { device: DeviceView; changed: boolean } | 'no_wipe_asked'; + +/** + * The device says it wiped. Null when the row is gone or the secret doesn't fit. + * Only a change is news: a device sends this again until it gets through, and + * anyone with a row could send it for ever. `admin` needs a request the row + * still has. Anyone can say they wiped themselves, but not that an admin told them. + */ +export async function confirmDeviceWipe(id: string, secret: string, reason: WipeReason, now = Date.now()): Promise { const current = await readDevice(id); if (!current || !secretMatches(current, secret)) return null; const at = new Date(now).toISOString(); + let refused = false; const { data } = await collection().update(id, (raw) => { + refused = false; const device = parse(raw); if (!device) return undefined; + if (device.wipedAt && !device.wipe) return undefined; + if (reason === 'admin' && !device.wipe) { + refused = true; + return undefined; + } return JSON.stringify({ ...device, wipe: null, wipedAt: at, wipeReason: reason, lastSeen: at }); }); + if (refused) return 'no_wipe_asked'; const device = parse(data); - return device ? toView(id, device) : null; + if (device) return { device: toView(id, device), changed: true }; + const after = await readDevice(id); + return after ? { device: toView(id, after), changed: false } : null; +} + +/** + * The account signed out here and took its data with it: the row has nothing + * left to look after. A wipe still waiting is answered by that, and stays on the + * record the way any confirmed wipe does. Null when the secret doesn't fit. + */ +export async function dropDevice(id: string, secret: string, now = Date.now()): Promise<{ confirmed: DeviceView | null } | null> { + const current = await readDevice(id); + if (!current) return { confirmed: null }; + if (!secretMatches(current, secret)) return null; + if (current.wipe) { + const answer = await confirmDeviceWipe(id, secret, 'user', now); + return { confirmed: answer && answer !== 'no_wipe_asked' && answer.changed ? answer.device : null }; + } + await collection().delete(id); + return { confirmed: null }; } /** Null when nothing changed: no such row, a wipe already waiting, or a row that confirmed and has nothing left. */ @@ -168,7 +221,8 @@ export async function requestDeviceWipe(id: string, by: string, now = Date.now() /** Every device of a user that hasn't wiped already, one request each. */ export async function requestUserWipe(userKey: string, by: string, now = Date.now()): Promise { - const ids = await collection().list(`${userKey}/`); + // No more than a user can have: nothing a stray write left behind makes this unbounded. + const ids = (await collection().list(`${userKey}/`)).slice(0, MAX_DEVICES_PER_USER); const results = await Promise.all(ids.map((id) => requestDeviceWipe(id, by, now))); return results.filter((device): device is DeviceView => device !== null); } From 940a57284410fe2a6f0b94103b0b512ec910dafd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:11:47 +0100 Subject: [PATCH 24/45] feat: hide an account's mail when its login is refused, until the new password or 30 days A refused login used to delete the account's data, and with it everyone's S/MIME and OpenPGP keys, also with offline off. The usual cause is a password changed on another device, which Stalwart 0.16 and 1.0 both answer with `invalid_grant`, so that lost unsent mail for little protection. - A refused login signs out at once and hides the data. A new password brings it back, unsent mail included. Otherwise it goes after 30 days. The sign-in page gives a count and a date. The 24-hour timer is gone. - `organisation.devices.wipeOnRefusedLogin`, off by default, asks for deletion at once, of that account's own data only. - The session limit no longer counts as a refusal on any route. It hides the data and says nothing. - A wipe runs under a Web Lock, tells other windows when it starts and ends, and queues its report before deleting anything. It reports only once the deletion worked and no plugin database is left. - More goes with a wipe: Pro tabs, search history, Files and WebDAV state, templates, pop-out drafts, shown notifications, push state, and Web Push with the last account. - The unsent count is per account, and unknown rather than 0 when the store can't say. A new row is checked against the account that asked, and a sign-out drops its row. Existing tests changed because the rule did: `auth-store-keep-local.test.ts` expected a wipe on `invalid_grant` and at the session limit, and `device-wipe.test.ts` expected shared keys to go on a refused login. --- app/(main)/[locale]/login/page.tsx | 22 +- app/api/plugin-secrets/__tests__/wipe.test.ts | 70 ++ app/api/plugin-secrets/route.ts | 14 + .../org/__tests__/offline-section.test.tsx | 10 + .../admin/org/sections/offline-section.tsx | 10 + .../__tests__/offline-settings.test.tsx | 23 + .../__tests__/wipe-device-section.test.tsx | 14 +- components/offline/device-wipe-host.tsx | 10 +- components/offline/offline-settings.tsx | 4 +- components/offline/wipe-device-section.tsx | 15 +- integration/tests/24-do-remote-wipe.spec.ts | 18 + .../server-device-offline-caps.test.ts | 13 + lib/admin/organisation-policy.ts | 8 + lib/admin/server-device-policy.ts | 14 +- lib/devices/__tests__/client.test.ts | 49 +- lib/devices/client.ts | 24 +- lib/devices/wipe-state.ts | 92 ++- lib/offline/__tests__/device-wipe.test.ts | 647 ++++++++++++++++-- lib/offline/__tests__/evicted-secrets.test.ts | 81 +++ lib/offline/credentials-rejected.ts | 18 +- lib/offline/device-wipe.ts | 412 ++++++++--- lib/offline/evicted-secrets.ts | 23 + lib/offline/offline-mode.ts | 51 +- lib/offline/state.ts | 23 + lib/popout/rescued-drafts.ts | 6 + lib/sign-out-cleanup.ts | 8 +- locales/ar/common.json | 7 +- locales/ca/common.json | 7 +- locales/cs/common.json | 7 +- locales/da/common.json | 7 +- locales/de/common.json | 7 +- locales/en/common.json | 7 +- locales/es/common.json | 7 +- locales/et/common.json | 7 +- locales/fa/common.json | 7 +- locales/fr/common.json | 7 +- locales/he/common.json | 7 +- locales/hu/common.json | 7 +- locales/it/common.json | 7 +- locales/ja/common.json | 7 +- locales/ko/common.json | 7 +- locales/lv/common.json | 7 +- locales/mn/common.json | 7 +- locales/nb/common.json | 7 +- locales/nl/common.json | 7 +- locales/pl/common.json | 7 +- locales/pt/common.json | 7 +- locales/ro/common.json | 7 +- locales/ru/common.json | 7 +- locales/sk/common.json | 7 +- locales/th/common.json | 7 +- locales/tr/common.json | 7 +- locales/uk/common.json | 7 +- locales/vi/common.json | 7 +- locales/zh-TW/common.json | 7 +- locales/zh/common.json | 7 +- .../__tests__/auth-store-device-wipe.test.ts | 115 +++- .../__tests__/auth-store-keep-local.test.ts | 21 +- stores/account-store.ts | 10 +- stores/auth-store.ts | 117 ++-- 60 files changed, 1827 insertions(+), 325 deletions(-) create mode 100644 app/api/plugin-secrets/__tests__/wipe.test.ts create mode 100644 lib/offline/__tests__/evicted-secrets.test.ts create mode 100644 lib/offline/evicted-secrets.ts diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index e5ea02def..92c6587bb 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -12,7 +12,8 @@ import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { useAuthStore, consumeSignedOut, SESSION_EXPIRED_DRAFTS_UNSAVED_KEY } from "@/stores/auth-store"; import { consumeLost } from "@/lib/offline/lost"; -import { consumeWipeNote } from "@/lib/devices/wipe-state"; +import { consumeWipeNote, onRefusedLoginsChanged, refusedWaits, type RefusedWait, type WipeNote } from "@/lib/devices/wipe-state"; +import { wipeNoteText } from "@/components/offline/device-wipe-host"; import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -285,10 +286,15 @@ function LoginPageContent() { // Offline outbox messages a sign-out deleted unsent (#523), named once. const [outboxLost, setOutboxLost] = useState([]); // Unsent mail a wipe took with it: how many, never which. - const [wipeLost, setWipeLost] = useState(null); + const [wipeLost, setWipeLost] = useState(null); + // Unsent mail that waits for a new password: how many and until when, never which. + const [refusedWaiting, setRefusedWaiting] = useState([]); useEffect(() => { setOutboxLost(consumeLost()); - setWipeLost(consumeWipeNote()?.unsent ?? null); + setWipeLost(consumeWipeNote()); + const read = () => setRefusedWaiting(refusedWaits()); + read(); + return onRefusedLoginsChanged(read); }, []); const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false); const [showPassword, setShowPassword] = useState(false); @@ -1462,9 +1468,17 @@ function LoginPageContent() {
{wipeLost !== null && (
- {wipeLost > 0 ? tOfflineWipe("note_unsent", { count: wipeLost }) : tOfflineWipe("note")} + {wipeNoteText(wipeLost, tOfflineWipe)}
)} + {refusedWaiting.map((wait) => { + const date = new Date(wait.until).toLocaleDateString(String(params?.locale ?? "en"), { dateStyle: "medium" }); + return ( +
+ {wait.unsent === null ? tOfflineWipe("refused_waiting_unknown", { date }) : tOfflineWipe("refused_waiting", { count: wait.unsent, date })} +
+ ); + })} {outboxLost.length > 0 && (
{tOffline("signout.lost", { subjects: outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })} diff --git a/app/api/plugin-secrets/__tests__/wipe.test.ts b/app/api/plugin-secrets/__tests__/wipe.test.ts new file mode 100644 index 000000000..075138ac9 --- /dev/null +++ b/app/api/plugin-secrets/__tests__/wipe.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Deleting everything of one user, which only makes sense where the server and its +// files are on the device: the desktop app. On an instance the same request is a +// way to delete someone else's connected accounts, so it has to do nothing. + +const deleted = vi.hoisted(() => [] as unknown[]); +const session = vi.hoisted(() => ({ signedIn: false })); +vi.mock('@/lib/stalwart/credentials', () => ({ + getStalwartCredentials: async () => (session.signedIn ? { accountName: 'me@example.org', serverUrl: 'https://mail.example.org', authHeader: 'x' } : null), +})); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled: true } }) }, +})); +vi.mock('@/lib/plugin-secrets/vault', () => ({ + VaultFullError: class extends Error {}, + deleteAllUserCredentials: vi.fn(async (user: unknown) => { deleted.push(user); }), + deleteUserCredentials: vi.fn(async () => {}), + listUserCredentials: vi.fn(async () => []), + putUserCredential: vi.fn(async () => {}), +})); + +async function remove(query: string, headers: Record = { 'sec-fetch-site': 'same-origin' }): Promise { + vi.resetModules(); + const { DELETE } = await import('../route'); + return DELETE(new NextRequest(`https://app.local/api/plugin-secrets?${query}`, { method: 'DELETE', headers })); +} + +const EVICTED = 'all=true&username=alice&server=https%3A%2F%2Fmail.example.org'; + +beforeEach(() => { + deleted.length = 0; + session.signedIn = false; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('DELETE /api/plugin-secrets?all=true', () => { + it('in the desktop app takes a dropped account by who it was, with no cookie', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect((await remove(EVICTED)).status).toBe(200); + expect(deleted).toEqual([{ username: 'alice', serverUrl: 'https://mail.example.org' }]); + }); + + it('won\'t take it from another site', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site' })).status).toBe(403); + expect(deleted).toEqual([]); + }); + + it('on an instance deletes nobody\'s, with a cookie or without', async () => { + expect((await remove(EVICTED)).status).toBe(401); + session.signedIn = true; + await remove(EVICTED); + expect(deleted).toEqual([]); + }); + + it('in the desktop app with a cookie and no name takes the signed-in account', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + session.signedIn = true; + expect((await remove('all=true')).status).toBe(200); + expect(deleted).toEqual([{ username: 'me@example.org', serverUrl: 'https://mail.example.org' }]); + }); +}); diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts index 81dd51d71..b767e0dbe 100644 --- a/app/api/plugin-secrets/route.ts +++ b/app/api/plugin-secrets/route.ts @@ -114,6 +114,20 @@ export async function POST(request: NextRequest) { export async function DELETE(request: NextRequest) { const crossOrigin = rejectCrossOriginRequest(request); if (crossOrigin) return crossOrigin; + // An account dropped earlier, in the desktop app: no cookie left, and this server serves this device alone. + const { searchParams } = request.nextUrl; + const owner = searchParams.get('username'); + const server = searchParams.get('server'); + if (IS_DESKTOP_BUILD && searchParams.get('all') === 'true' && owner && server) { + try { + await deleteAllUserCredentials({ username: owner, serverUrl: server }); + return NextResponse.json({ ok: true }, { headers: NO_STORE }); + } catch (error) { + const unavailable = storedDataUnavailableResponse(error, NO_STORE); + if (unavailable) return unavailable; + throw error; + } + } const user = await currentUser(request); if (user instanceof NextResponse) return user; // A wipe in the desktop app, where this server and its files are on the device. diff --git a/components/admin/org/__tests__/offline-section.test.tsx b/components/admin/org/__tests__/offline-section.test.tsx index a5051d4fb..dfcbe2415 100644 --- a/components/admin/org/__tests__/offline-section.test.tsx +++ b/components/admin/org/__tests__/offline-section.test.tsx @@ -59,6 +59,16 @@ describe('Offline mode section', () => { expect((puts[0].organisation as { devices: { noOffline: boolean } }).devices.noOffline).toBe(true); }); + 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'); + 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); + }); + it('writes a scope override only into that scope', async () => { await renderSection(); act(() => latest!.setScopeId('field')); diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 359648627..ed51c2708 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -50,6 +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'); return ( draft.clearOrgValue('devices', 'noOfflineAttachments')} />
+ +
+ draft.setOrgValue('devices', 'wipeOnRefusedLogin', v)} testId="org-devices-wipeOnRefusedLogin" /> + draft.clearOrgValue('devices', 'wipeOnRefusedLogin')} /> +
+

What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything), with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx index 3113b413e..66e545e40 100644 --- a/components/offline/__tests__/offline-settings.test.tsx +++ b/components/offline/__tests__/offline-settings.test.tsx @@ -46,6 +46,29 @@ describe('Settings > Offline', () => { } }); + it('offers removing everything where offline mode exists', () => { + render(); + expect(screen.getByTestId('wipe-device-section')).toBeTruthy(); + }); + + it('doesn\'t where it can\'t be used: a private window, a store that isn\'t there, an organisation\'s no', () => { + useOfflineStore.setState({ storageBlocked: true }); + const privateWindow = render(); + expect(screen.queryByTestId('wipe-device-section')).toBeNull(); + privateWindow.unmount(); + useOfflineStore.setState({ storageBlocked: false }); + + setStoreBackend({ supported: () => false } as unknown as StoreBackend); + const noStore = render(); + expect(screen.queryByTestId('wipe-device-section')).toBeNull(); + noStore.unmount(); + setStoreBackend({ supported: () => true } as unknown as StoreBackend); + + setNoOffline(true); + render(); + expect(screen.queryByTestId('wipe-device-section')).toBeNull(); + }); + it('is off until the user turns it on in this browser', () => { render(); const toggle = screen.getByTestId('offline-switch'); diff --git a/components/offline/__tests__/wipe-device-section.test.tsx b/components/offline/__tests__/wipe-device-section.test.tsx index ca45e54fa..d07d03149 100644 --- a/components/offline/__tests__/wipe-device-section.test.tsx +++ b/components/offline/__tests__/wipe-device-section.test.tsx @@ -1,9 +1,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const wipe = vi.hoisted(() => ({ whole: vi.fn(async () => {}), unsent: 0 })); +const wipe = vi.hoisted(() => ({ whole: vi.fn(async () => {}), unsent: 0 as number | null })); vi.mock('@/lib/offline/device-wipe', () => ({ wipeWholeDevice: wipe.whole })); -vi.mock('@/lib/offline/offline-mode', () => ({ unsentCount: async () => ({ messages: wipe.unsent, changes: 0 }) })); +vi.mock('@/lib/offline/offline-mode', () => ({ unsentForWipe: async () => wipe.unsent })); import { WipeDeviceSection } from '../wipe-device-section'; @@ -17,7 +17,7 @@ describe('the wipe in settings', () => { render(); fireEvent.click(screen.getByTestId('wipe-device-button')); const details = await screen.findByTestId('wipe-device-details'); - for (const key of ['confirm_mail', 'confirm_keys', 'confirm_plugins', 'confirm_files', 'confirm_accounts']) { + for (const key of ['confirm_mail', 'confirm_keys', 'confirm_plugins', 'confirm_files', 'confirm_templates', 'confirm_accounts']) { expect(details.textContent).toContain(key); } expect(details.textContent).not.toContain('confirm_unsent'); @@ -31,6 +31,14 @@ describe('the wipe in settings', () => { expect((await screen.findByTestId('wipe-device-details')).textContent).toContain('confirm_unsent'); }); + it('says it can\'t tell, not that there\'s nothing, when the count is unknown', async () => { + wipe.unsent = null; + render(); + fireEvent.click(screen.getByTestId('wipe-device-button')); + const details = await screen.findByTestId('wipe-device-details'); + expect(details.textContent).toContain('confirm_unsent_unknown'); + }); + it('wipes only after the yes, as the user\'s own', async () => { render(); fireEvent.click(screen.getByTestId('wipe-device-button')); diff --git a/components/offline/device-wipe-host.tsx b/components/offline/device-wipe-host.tsx index 29229e005..3816ca38d 100644 --- a/components/offline/device-wipe-host.tsx +++ b/components/offline/device-wipe-host.tsx @@ -2,11 +2,17 @@ import { useEffect, useRef } from 'react'; import { useTranslations } from 'next-intl'; -import { consumeWipeNote } from '@/lib/devices/wipe-state'; +import { consumeWipeNote, 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'; +/** What a wipe left to say, as a sentence. Counts only. */ +export function wipeNoteText(note: WipeNote, t: (key: string, values?: { count: number }) => string): string { + if (note.unknown) return t('note_unknown'); + return note.unsent > 0 ? t('note_unsent', { count: note.unsent }) : t('note'); +} + /** * Runs the device's side of wipes on every page, the sign-in page too, so a * device with nobody signed in still finishes a wipe and hears about a new one. @@ -24,7 +30,7 @@ export function DeviceWipeHost() { useEffect(() => { if (!signedIn) return; const note = consumeWipeNote(); - if (note) toast.warning(note.unsent > 0 ? tRef.current('note_unsent', { count: note.unsent }) : tRef.current('note'), { duration: 0 }); + if (note) toast.warning(wipeNoteText(note, tRef.current), { duration: 0 }); }, [signedIn]); return null; diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index db5c1ef0d..1bfd3706a 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'; import { IS_LITE } from '@/lib/lite'; import { isDesktopApp } from '@/lib/desktop/bridge'; import { signedInAccountIds } from '@/lib/device-policy'; -import { offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode'; +import { offlineAllowed, offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode'; import { useOfflineStore } from '@/lib/offline/state'; import { useDevicePolicy } from '@/hooks/use-device-policy'; import { useAccountStore } from '@/stores/account-store'; @@ -82,10 +82,10 @@ export function OfflineSettings() { heading={several ? labels.find((entry) => entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null} /> ))} + {[active, ...accounts].some((account) => offlineAllowed(account)) && } )} - ); } diff --git a/components/offline/wipe-device-section.tsx b/components/offline/wipe-device-section.tsx index a5676879f..d620843cf 100644 --- a/components/offline/wipe-device-section.tsx +++ b/components/offline/wipe-device-section.tsx @@ -5,7 +5,10 @@ import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { wipeWholeDevice } from '@/lib/offline/device-wipe'; -import { unsentCount } from '@/lib/offline/offline-mode'; +import { unsentForWipe } from '@/lib/offline/offline-mode'; + +/** The question doesn't wait as long for the count as the wipe itself does. */ +const QUESTION_WAIT_MS = 2_000; /** * "Remove everything from this device", for someone giving a computer away or @@ -14,12 +17,12 @@ import { unsentCount } from '@/lib/offline/offline-mode'; export function WipeDeviceSection() { const t = useTranslations('offlineWipe'); const [asking, setAsking] = useState(false); - const [unsent, setUnsent] = useState(0); + // Null: the store couldn't say, which isn't the same as none. + const [unsent, setUnsent] = useState(0); const [wiping, setWiping] = useState(false); const ask = async () => { - const { messages } = await unsentCount('all').catch(() => ({ messages: 0 })); - setUnsent(messages); + setUnsent(await unsentForWipe('all', QUESTION_WAIT_MS).catch(() => null)); setAsking(true); }; @@ -55,10 +58,12 @@ export function WipeDeviceSection() { details={(

  • {t('confirm_mail')}
  • - {unsent > 0 &&
  • {t('confirm_unsent', { count: unsent })}
  • } + {unsent === null &&
  • {t('confirm_unsent_unknown')}
  • } + {unsent !== null && unsent > 0 &&
  • {t('confirm_unsent', { count: unsent })}
  • }
  • {t('confirm_keys')}
  • {t('confirm_plugins')}
  • {t('confirm_files')}
  • +
  • {t('confirm_templates')}
  • {t('confirm_accounts')}
)} diff --git a/integration/tests/24-do-remote-wipe.spec.ts b/integration/tests/24-do-remote-wipe.spec.ts index d88ac072a..5938e9bbd 100644 --- a/integration/tests/24-do-remote-wipe.spec.ts +++ b/integration/tests/24-do-remote-wipe.spec.ts @@ -354,6 +354,24 @@ test('the user removes everything from this device, from settings', async ({ bro await Promise.all([page.context().close(), admin.close()]); }); +test('the sign-in page says what waits for a new password, in numbers', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const page = await context.newPage(); + await page.goto('/login'); + await expect(page.locator('#username')).toBeVisible({ timeout: 30_000 }); + const at = Date.now(); + await page.evaluate((now) => { + localStorage.setItem('bulwark-offline-evicted', JSON.stringify({ 'someone@example.org': now })); + localStorage.setItem('bulwark-credentials-refused', JSON.stringify({ 'someone@example.org': { at: now, unsent: 3, certain: true } })); + }, at); + await page.reload(); + const note = page.getByTestId('login-refused-waiting'); + await expect(note).toBeVisible({ timeout: 30_000 }); + await expect(note).toContainText('3 unsent messages wait for your new password'); + await expect(note).not.toContainText('someone'); + await context.close(); +}); + test('an account without offline mode has no row', async ({ browser }) => { const admin = await adminContext(browser); await forgetDevices(admin, ENT.carol); diff --git a/lib/admin/__tests__/server-device-offline-caps.test.ts b/lib/admin/__tests__/server-device-offline-caps.test.ts index a6661ea25..1b9ba98e4 100644 --- a/lib/admin/__tests__/server-device-offline-caps.test.ts +++ b/lib/admin/__tests__/server-device-offline-caps.test.ts @@ -1,11 +1,24 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_POLICY, type SettingsPolicy } from '../types'; import { ALL_DEVICE_LIMITS, strictestOfAllScopes } from '../server-device-policy'; +import { sanitizeOrganisationPartial } from '../organisation-policy'; // The offline caps are numbers in a section that used to hold only booleans. const scope = (id: string, devices: Record) => ({ id, label: id, match: { domains: [`${id}.example.org`] }, policy: { organisation: { devices } } }); +describe('wipeOnRefusedLogin', () => { + 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); + }); + + 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); + }); +}); + describe('strictest of all scopes', () => { it('keeps the lowest cap, and a boolean switch still turns on', () => { const policy = { diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index b49c3f812..e8e6f5d4f 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -175,6 +175,12 @@ export interface OrganisationPolicy { offlineMaxAttachmentMb: number; /** 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. + */ + wipeOnRefusedLogin: boolean; }; calendars: { /** Adds up across scopes by id. */ @@ -200,6 +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, }, calendars: { feeds: [], freeBusyGroup: '' }, }; @@ -544,6 +551,7 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial [key, typeof off === 'number' ? 1 : true]), -) as ServerDevicePolicy; +/** + * Everything on, and each cap as low as it goes: when not even the policy can be + * read. Except deleting a device's mail, which is no limit on anything the server does. + */ +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, +} as ServerDevicePolicy; /** * The device policy that applies to this login. When its session can't be diff --git a/lib/devices/__tests__/client.test.ts b/lib/devices/__tests__/client.test.ts index 6be4a6225..0ee6777c1 100644 --- a/lib/devices/__tests__/client.test.ts +++ b/lib/devices/__tests__/client.test.ts @@ -40,6 +40,16 @@ describe('a check-in', () => { expect(await checkInWithInstance(record)).toBe('unavailable'); }); + it('hears that its row was wiped', async () => { + answer(200, JSON.stringify({ wipe: null, wiped: true })); + expect(await checkInWithInstance({ id: 'r', secret: 's' })).toBe('wiped'); + }); + + it('reads a 429 as nothing', async () => { + answer(429, JSON.stringify({ error: 'rate limited' })); + expect(await checkInWithInstance({ id: 'r', secret: 's' })).toBe('unavailable'); + }); + it('reads no network as nothing', async () => { vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); expect(await checkInWithInstance(record)).toBe('unavailable'); @@ -48,13 +58,24 @@ describe('a check-in', () => { describe('registering', () => { it('names the account\'s cookie slot', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify(record), { status: 200 })); + const row = { ...record, user: 'a@b.c', server: 'https://b.c' }; + const fetchMock = vi.fn(async () => new Response(JSON.stringify(row), { status: 200 })); vi.stubGlobal('fetch', fetchMock); - expect(await registerWithInstance(3)).toEqual(record); + expect(await registerWithInstance(3)).toEqual(row); const init = (fetchMock.mock.calls as unknown as [string, RequestInit][])[0][1]; expect((init.headers as Record)['X-JMAP-Cookie-Slot']).toBe('3'); }); + it('hands back the owner the instance gave', async () => { + answer(200, JSON.stringify({ id: 'r', secret: 's', user: 'a@b.c', server: 'https://b.c' })); + expect(await registerWithInstance(0)).toEqual({ id: 'r', secret: 's', user: 'a@b.c', server: 'https://b.c' }); + }); + + it('won\'t take a row with no owner', async () => { + answer(200, JSON.stringify({ id: 'r', secret: 's' })); + expect(await registerWithInstance(0)).toBeNull(); + }); + it('gives null when it can\'t', async () => { answer(401, '{}'); expect(await registerWithInstance(0)).toBeNull(); @@ -81,6 +102,30 @@ describe('reporting a wipe', () => { }); }); +describe('a confirmation the instance turned down', () => { + it('is done when no wipe was asked: sending it again can\'t change that', async () => { + answer(409, JSON.stringify({ error: 'no_wipe_asked' })); + expect(await reportWipeToInstance({ id: 'r', secret: 's', reason: 'admin', unsent: 0 })).toBe(true); + }); + + it('tries again after a 429', async () => { + answer(429, JSON.stringify({ error: 'rate limited' })); + expect(await reportWipeToInstance({ id: 'r', secret: 's', reason: 'admin', unsent: 0 })).toBe(false); + }); + + it('sends the row, the reason and the count, and no account name', async () => { + answer(200, JSON.stringify({ ok: true })); + await reportWipeToInstance({ id: 'r', secret: 's', reason: 'admin', unsent: null, account: 'alice@example.org' }); + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toEqual({ id: 'r', secret: 's', reason: 'admin', unsent: null }); + }); + + it('a row dropped at sign-out says so', async () => { + answer(200, JSON.stringify({ ok: true })); + await reportWipeToInstance({ id: 'r', secret: 's', reason: 'user', unsent: 0, drop: true }); + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toEqual({ id: 'r', secret: 's', drop: true }); + }); +}); + describe('what a row calls the device', () => { it('names the desktop app', () => { (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; diff --git a/lib/devices/client.ts b/lib/devices/client.ts index 160f6a2d1..2161b3724 100644 --- a/lib/devices/client.ts +++ b/lib/devices/client.ts @@ -10,7 +10,15 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { DeviceRecord } from '@/lib/store/client/keys'; import type { WipeConfirmation } from './wipe-state'; -export type CheckInAnswer = 'wipe' | 'clear' | 'forgotten' | 'unavailable'; +export type CheckInAnswer = 'wipe' | 'wiped' | 'clear' | 'forgotten' | 'unavailable'; + +/** A new row and who the instance gave it to: the device checks that's the account it asked for. */ +export interface RegisteredRow { + id: string; + secret: string; + user: string; + server: string; +} /** Short enough for one row in a list. Nothing identifying beyond what a browser sends anyway. */ export function describePlatform(): string { @@ -55,12 +63,13 @@ function post(path: string, body: unknown, signal?: AbortSignal, headers: Record } /** A new row, for the account signed in on this cookie slot. Null if that can't be done now. */ -export async function registerWithInstance(slot: number, signal?: AbortSignal): Promise<{ id: string; secret: string } | null> { +export async function registerWithInstance(slot: number, signal?: AbortSignal): Promise { try { const response = await post('/api/devices', deviceInfo(), signal, { 'X-JMAP-Cookie-Slot': String(slot) }); if (!response.ok) return null; const body = await readJson(response); - return typeof body?.id === 'string' && typeof body.secret === 'string' ? { id: body.id, secret: body.secret } : null; + if (typeof body?.id !== 'string' || typeof body.secret !== 'string' || typeof body.user !== 'string' || typeof body.server !== 'string') return null; + return { id: body.id, secret: body.secret, user: body.user, server: body.server }; } catch { return null; } @@ -72,7 +81,8 @@ export async function checkInWithInstance(record: Pick { try { - const response = await post('/api/devices/wiped', entry, signal); + const { id, secret, reason, unsent, drop } = entry; + const response = await post('/api/devices/wiped', drop ? { id, secret, drop: true } : { id, secret, reason, unsent }, signal); if (response.ok) return true; - return response.status === 404 && (await readJson(response))?.error === 'unknown_device'; + const error = (await readJson(response))?.error; + return (response.status === 404 && error === 'unknown_device') || (response.status === 409 && error === 'no_wipe_asked'); } catch { return false; } diff --git a/lib/devices/wipe-state.ts b/lib/devices/wipe-state.ts index 72ab7b653..2b8d9b749 100644 --- a/lib/devices/wipe-state.ts +++ b/lib/devices/wipe-state.ts @@ -4,6 +4,7 @@ * is a count, a row id and the secret for that one row. */ +import { EVICTED_KEEP_MS, readEvicted } from '@/lib/offline/state'; import type { WipeReason } from './types'; const PENDING_KEY = 'bulwark-wipe-pending'; @@ -21,14 +22,6 @@ export const WHOLE_DEVICE = '*'; */ export const CREDENTIALS_REFUSED_CONFIRM_MS = 60_000; -/** - * How long a refused login's data waits for a sign-in with the new password. - * Nothing but this 401 says the login is over, since a password login has no - * `invalid_grant`. A server being restored from a backup says the same for hours, - * and someone who changed their password elsewhere is about to type the new one. - */ -export const REFUSED_KEEP_MS = 24 * 60 * 60 * 1000; - function readJson(key: string, fallback: T): T { try { const raw = localStorage.getItem(key); @@ -69,7 +62,12 @@ export interface WipeConfirmation { id: string; secret: string; reason: WipeReason; - unsent: number; + /** Null: it couldn't be counted. Not the same as none. */ + unsent: number | null; + /** Whose wipe this says is done: not sent until it is. Stays on this device. */ + account?: string; + /** Not a wipe but a sign-out, whose row goes too. */ + drop?: boolean; } export function readWipeConfirmations(): WipeConfirmation[] { @@ -88,32 +86,83 @@ export function dropWipeConfirmation(id: string): void { // ---- The note at the next sign-in: a count, never subjects ---- -export function addWipeNote(unsent: number): void { - const before = readJson<{ unsent?: number }>(NOTE_KEY, {}); - writeJson(NOTE_KEY, { unsent: (before.unsent ?? 0) + unsent }, false); +export interface WipeNote { + unsent: number; + /** Some of it couldn't be counted, so there may have been more than `unsent`. */ + unknown?: true; +} + +export function addWipeNote(unsent: number | null): void { + const before = readJson(NOTE_KEY, null); + const note: WipeNote = { unsent: (before?.unsent ?? 0) + (unsent ?? 0) }; + if (before?.unknown || unsent === null) note.unknown = true; + writeJson(NOTE_KEY, note, false); } /** Once: whoever shows it takes it. Null when no wipe left a word. */ -export function consumeWipeNote(): { unsent: number } | null { - const note = readJson<{ unsent?: number } | null>(NOTE_KEY, null); +export function consumeWipeNote(): WipeNote | null { + const note = readJson(NOTE_KEY, null); writeJson(NOTE_KEY, null, true); - return note && typeof note.unsent === 'number' ? { unsent: note.unsent } : null; + if (!note || typeof note.unsent !== 'number') return null; + return note.unknown ? { unsent: note.unsent, unknown: true } : { unsent: note.unsent }; } -// ---- A refused password: when the wait started ---- +// ---- A refused login: what waits for the new password. A count, never subjects ---- -export function readRefusedLogins(): Record { - return readJson>(REFUSED_KEY, {}); +export interface RefusedLogin { + at: number; + /** Unsent messages waiting. Null: not counted yet, or the store couldn't say. */ + unsent: number | null; + /** Not one bad answer at a start: what a deletion the organisation asked for needs. */ + certain: boolean; } -export function markLoginRefused(account: string, now = Date.now()): void { - writeJson(REFUSED_KEY, { ...readRefusedLogins(), [account]: now }, false); +export function readRefusedLogins(): Record { + return readJson>(REFUSED_KEY, {}); +} + +const refusedListeners = new Set<() => void>(); + +/** For the sign-in page: the count comes after the page does. */ +export function onRefusedLoginsChanged(listener: () => void): () => void { + refusedListeners.add(listener); + return () => refusedListeners.delete(listener); +} + +function writeRefused(refused: Record): void { + writeJson(REFUSED_KEY, refused, Object.keys(refused).length === 0); + for (const listener of [...refusedListeners]) listener(); +} + +export function markLoginRefused(account: string, certain = true, now = Date.now()): void { + const before = readRefusedLogins(); + writeRefused({ ...before, [account]: { at: before[account]?.at ?? now, unsent: null, certain: certain || before[account]?.certain === true } }); +} + +export function setRefusedCount(account: string, unsent: number | null): void { + const before = readRefusedLogins(); + if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent } }); } export function dropRefusedLogin(account: string): void { const refused = readRefusedLogins(); + if (!(account in refused)) return; delete refused[account]; - writeJson(REFUSED_KEY, refused, Object.keys(refused).length === 0); + writeRefused(refused); +} + +export interface RefusedWait { + unsent: number | null; + /** When the data goes if nobody signs in. */ + until: number; +} + +/** What the sign-in page says. Numbers only: whoever reads it may not be the person it's about. */ +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 })); } // ---- Plugin databases a running frame kept open: tried again at the next load ---- @@ -130,6 +179,5 @@ export function writeBlockedDatabases(blocked: Record): void { export function wipeWorkWaiting(): boolean { return Object.keys(readPendingWipes()).length > 0 || readWipeConfirmations().length > 0 - || Object.keys(readRefusedLogins()).length > 0 || Object.keys(readBlockedDatabases()).length > 0; } diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index a0bac109f..72570611e 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -1,15 +1,23 @@ import 'fake-indexeddb/auto'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // What a wipe removes and what makes one happen. Real auth store, real offline // mode, the store's real engine, real IndexedDB (fake-indexeddb): only the // network is a stub, and the OPFS pool, which jsdom doesn't have. vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })) })); -const purge = vi.hoisted(() => ({ files: 0 })); +const purge = vi.hoisted(() => ({ files: 0, fail: false, during: null as null | (() => void) })); vi.mock('@/lib/store/client/purge', async () => { const keys = await vi.importActual('@/lib/store/client/keys'); - return { purgeStoreFiles: async () => { purge.files += 1; keys.deleteKeyDatabase(); }, deleteStorePool: async () => {} }; + return { + purgeStoreFiles: async () => { + purge.during?.(); + if (purge.fail) throw new Error('The files are held'); + purge.files += 1; + keys.deleteKeyDatabase(); + }, + deleteStorePool: async () => {}, + }; }); // A store and a full sign-out in each test: slow on a machine doing other work. @@ -38,6 +46,8 @@ let sent: Array<{ url: string; body: Record; headers: Record new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); const noWipe = () => json(200, { wipe: null }); +/** What the route gives a new row. */ +const newRow = (id = ROW) => json(200, { id, secret: SECRET, user: 'alice', server: 'https://mail.example.test' }); const wipeNow = () => json(200, { wipe: { requestedAt: '2026-11-03T00:00:00.000Z' } }); function entry(id: string, cookieSlot: number) { @@ -69,6 +79,8 @@ async function freshIndexedDb(): Promise { beforeEach(async () => { vi.resetModules(); purge.files = 0; + purge.fail = false; + purge.during = null; localStorage.clear(); sessionStorage.clear(); await freshIndexedDb(); @@ -291,7 +303,7 @@ describe('what never wipes', () => { it('a forgotten row: this device registers anew and keeps its data', async () => { respond = (_url, init) => { const { id } = JSON.parse(String(init?.body)) as { id?: string }; - return id ? json(404, { error: 'unknown_device' }) : json(200, { id: BOB_ROW, secret: SECRET }); + return id ? json(404, { error: 'unknown_device' }) : newRow(BOB_ROW); }; await wipe.runDeviceGate(); expectUntouched(); @@ -336,7 +348,7 @@ describe('registering', () => { }); it('a browser with offline on registers, once, naming its cookie slot', async () => { - respond = () => json(200, { id: ROW, secret: SECRET }); + respond = () => newRow(); await Promise.all([wipe.checkInDue(true), wipe.checkInDue(true)]); expect(sent).toHaveLength(1); expect(sent[0].headers['X-JMAP-Cookie-Slot']).toBe('0'); @@ -353,7 +365,7 @@ describe('registering', () => { it('a desktop app on an instance does, with offline off too', async () => { state.writeEnabled([]); (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; - respond = () => json(200, { id: ROW, secret: SECRET }); + respond = () => newRow(); await wipe.checkInDue(true); expect(sent).toHaveLength(1); }); @@ -365,10 +377,10 @@ describe('registering', () => { }); it('a cleared key database means a new row', async () => { - respond = () => json(200, { id: ROW, secret: SECRET }); + respond = () => newRow(); await wipe.checkInDue(true); keys.deleteKeyDatabase(); - respond = () => json(200, { id: BOB_ROW, secret: SECRET }); + respond = () => newRow(BOB_ROW); await wipe.checkInDue(true); expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW); }); @@ -381,6 +393,36 @@ describe('registering', () => { expect(await keys.listDeviceRecords()).toEqual([]); }); + it.each([ + ['someone else\'s row', { user: 'mallory', server: 'https://mail.example.test' }], + ['another server\'s row', { user: 'alice', server: 'https://other.example.test' }], + ])('drops %s: no record here, and the row goes back', async (_name, owner) => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : json(200, { id: BOB_ROW, secret: SECRET, ...owner })); + await wipe.checkInDue(true); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ id: BOB_ROW, secret: SECRET, drop: true })); + }); + + it.each([ + ['the same name in other case', { user: 'ALICE', server: 'https://mail.example.test/' }], + ['the whole address for a bare name', { user: 'alice@mail.example.test', server: 'https://mail.example.test' }], + ])('takes %s as the same owner', async (_name, owner) => { + respond = () => json(200, { id: ROW, secret: SECRET, ...owner }); + await wipe.checkInDue(true); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW); + }); + + it('a row that was wiped already: the record goes, nothing else does, and it registers anew', async () => { + await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 }); + respond = (_url, init) => (JSON.parse(String(init?.body)).id ? json(200, { wipe: null, wiped: true }) : newRow(BOB_ROW)); + await wipe.checkInDue(true); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + expectUntouched(); + await wipe.checkInDue(true); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + it('isn\'t tried again every minute when it can\'t', async () => { respond = () => json(401, {}); const now = Date.now(); @@ -403,28 +445,200 @@ describe('check-ins while running', () => { }); }); +/** What the auth store does around the hook. */ +function refuse(account = ALICE, certain = true): void { + wipeState.markLoginRefused(account, certain); + rejected.noteCredentialsRejected(account, certain); + useAccountStore.getState().removeAccount(account, 'evicted'); +} + +async function policyAsksForDeletion(): Promise { + const { usePolicyStore } = await import('@/stores/policy-store'); + const policy = { organisation: { devices: { wipeOnRefusedLogin: true } } }; + usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true, source: 'server', partial: false, resolveFor: () => policy } as never); +} + +const DAY = 24 * 60 * 60 * 1000; + describe('a refused login (lib/offline/credentials-rejected.ts)', () => { - it('takes everything beyond the store too, and tells the instance why', async () => { + it('hides the data and deletes nothing: everyone\'s keys, plugin settings and the store stay', async () => { await openDb('smime-plugin-store'); - respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + await openDb('bulwark-openpgp'); + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState({ plugins: [{ id: 'managed', settings: { apiKey: 'k' } }] as never }); wipe.registerDeviceWipe(); - rejected.noteCredentialsRejected(ALICE); - await vi.waitFor(() => expect(sent.some((call) => call.url.endsWith('/wiped'))).toBe(true)); - expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', id: ROW }); - await vi.waitFor(async () => expect(await databases()).not.toContain('smime-plugin-store')); - // The auth store is on its way with its own sign-out. - expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([ALICE]); + refuse(); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(0)); + + expect(state.isEvicted(ALICE)).toBe(true); + expect(state.visibleAccounts()).toEqual([]); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(await databases()).toEqual(expect.arrayContaining(['smime-plugin-store', 'bulwark-openpgp'])); + expect(usePluginStore.getState().plugins[0].settings).toEqual({ apiKey: 'k' }); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); }); - it('names the lost mail, as for any sign-out nobody asked for, and leaves no count note', async () => { + it('two weeks later a sign-in with the new password finds everything, unsent mail included', async () => { await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); - const lost = await import('../lost'); wipe.registerDeviceWipe(); - rejected.noteCredentialsRejected(ALICE); - await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); - await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); - expect(lost.consumeLost()).toEqual(['Unsent']); - expect(wipeState.consumeWipeNote()).toBeNull(); + const at = Date.now(); + refuse(); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(1)); + + const offline = await import('../offline-mode'); + const later = at + 14 * DAY; + await offline.sweepAccountsGone(later); + await wipe.sweepRefusedLogins(); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + + // What the sign-in page says, in numbers only. + const [wait] = wipeState.refusedWaits(); + expect(wait).toEqual({ unsent: 1, until: expect.any(Number) }); + expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS); + expect(wait.until).toBeLessThanOrEqual(at + state.EVICTED_KEEP_MS + 60_000); + expect(JSON.stringify({ ...localStorage })).not.toContain('"Unsent"'); + + useAccountStore.getState().addAccount(entry(ALICE, 0) as never); + expect(state.isEvicted(ALICE)).toBe(false); + expect(state.visibleAccounts()).toEqual([ALICE]); + expect((await queue.readQueue(ALICE)).outbox.map((row) => row.summary.subject)).toEqual(['Unsent']); + + await wipe.sweepRefusedLogins(); + expect(wipeState.readRefusedLogins()).toEqual({}); + expect(wipeState.refusedWaits()).toEqual([]); + }); + + it('with no sign-in for 30 days it goes, and names nothing to whoever is here', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + const at = Date.now(); + refuse(); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(1)); + const offline = await import('../offline-mode'); + await offline.sweepAccountsGone(at + 29 * DAY); + expect(purge.files).toBe(0); + await offline.sweepAccountsGone(at + 31 * DAY); + expect(purge.files).toBeGreaterThan(0); + await wipe.sweepRefusedLogins(); + expect(wipeState.refusedWaits()).toEqual([]); + const lost = await import('../lost'); + expect(lost.consumeLost()).toEqual([]); + }); + + it('a count it can\'t get is unknown, not none, and is tried again at the next look', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + store.failArea = () => true; + wipe.registerDeviceWipe(); + refuse(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(wipeState.refusedWaits()[0].unsent).toBeNull(); + store.failArea = null; + await wipe.sweepRefusedLogins(); + expect(wipeState.refusedWaits()[0].unsent).toBe(1); + }); + + it('keeps no wait for an account with nothing kept here', async () => { + state.writeEnabled([]); + wipe.registerDeviceWipe(); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + rejected.noteCredentialsRejected(ALICE, true); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(wipeState.refusedWaits()).toEqual([]); + expect(purge.files).toBe(0); + }); + + describe('with the organisation asking for deletion at once', () => { + beforeEach(async () => { + await policyAsksForDeletion(); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + }); + + it('takes that account\'s data and nothing shared, and tells the instance why', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + await openDb('smime-plugin-store'); + await openDb('bulwark-openpgp'); + const { fileStorage } = await import('@/lib/plugin-storage'); + const clearFiles = vi.spyOn(fileStorage, 'clearFiles').mockResolvedValue(); + const handoffs = await import('@/lib/launch-handoff'); + const clearHandoffs = vi.spyOn(handoffs, 'clearHandoffs'); + const { pluginStoragePrefix } = await import('@/lib/plugin-sandbox/storage-scope'); + localStorage.setItem(`${pluginStoragePrefix('notes', ALICE)}note`, 'hers'); + localStorage.setItem(`${pluginStoragePrefix('notes', BOB)}note`, 'his'); + localStorage.setItem('notification-center', '[{"title":"From Eve"}]'); + localStorage.setItem('template-storage', '{"templates":[]}'); + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState({ plugins: [{ id: 'managed', settings: { apiKey: 'k' } }] as never }); + + wipe.registerDeviceWipe(); + refuse(); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', id: ROW })); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + + expect(store.removed).toEqual([ALICE]); + expect(await databases()).toEqual(expect.arrayContaining(['smime-plugin-store', 'bulwark-openpgp'])); + expect(usePluginStore.getState().plugins[0].settings).toEqual({ apiKey: 'k' }); + expect(localStorage.getItem(`${pluginStoragePrefix('notes', ALICE)}note`)).toBeNull(); + expect(localStorage.getItem(`${pluginStoragePrefix('notes', BOB)}note`)).toBe('his'); + expect(localStorage.getItem('notification-center')).not.toBeNull(); + expect(localStorage.getItem('template-storage')).not.toBeNull(); + expect(clearFiles).not.toHaveBeenCalled(); + expect(clearHandoffs).not.toHaveBeenCalled(); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); + expect(state.useOfflineStore.getState().enabled).toEqual([BOB]); + }); + + it('with offline off touches nothing but what\'s the account\'s own', async () => { + state.writeEnabled([]); + await openDb('smime-plugin-store'); + const handoffs = await import('@/lib/launch-handoff'); + const clearHandoffs = vi.spyOn(handoffs, 'clearHandoffs'); + const { pluginStoragePrefix } = await import('@/lib/plugin-sandbox/storage-scope'); + localStorage.setItem(`${pluginStoragePrefix('notes', ALICE)}note`, 'hers'); + localStorage.setItem('notification-center', '[{"title":"From Eve"}]'); + wipe.registerDeviceWipe(); + rejected.noteCredentialsRejected(ALICE, true); + await vi.waitFor(() => expect(localStorage.getItem(`${pluginStoragePrefix('notes', ALICE)}note`)).toBeNull()); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(await databases()).toContain('smime-plugin-store'); + expect(localStorage.getItem('notification-center')).not.toBeNull(); + expect(clearHandoffs).not.toHaveBeenCalled(); + }); + + it('records a real count for the audit, however it\'s named here', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + refuse(); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', unsent: 1 })); + }); + + it('doesn\'t delete on a refusal that hasn\'t lasted: one bad answer at a start only hides', async () => { + wipe.registerDeviceWipe(); + refuse(ALICE, false); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(0)); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + }); + + it('reaches a refusal from before the policy said so', async () => { + const { usePolicyStore } = await import('@/stores/policy-store'); + const none = { organisation: { devices: {} } }; + usePolicyStore.setState({ policy: none, rawPolicy: none, resolveFor: () => none } as never); + const stop = wipe.startDeviceWipe(); + refuse(); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(0)); + expect(purge.files).toBe(0); + await policyAsksForDeletion(); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + stop(); + }); }); describe('from a running renewal', () => { @@ -454,59 +668,42 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => { expect(wipeState.readPendingWipes()).toEqual({}); }); - it.each([ - ['invalid_grant', 'invalid_grant'], - ['the session limit', 'session_expired'], - ])('%s fires it and wipes', async (_name, error) => { + it('invalid_grant fires it, signs out and hides: nothing is deleted', async () => { wipe.registerDeviceWipe(); - token(() => json(401, { error, maxHours: 8 })); + token(() => json(401, { error: 'invalid_grant' })); await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); expect(heard).toEqual([ALICE]); - await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expect(state.isEvicted(ALICE)).toBe(true); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); }); - }); - it('a password refused for a day goes, unless it signed in again', async () => { - const heard: string[] = []; - rejected.onCredentialsRejected((account) => heard.push(account)); - state.markEvicted(ALICE); - state.writeEnabled([ALICE]); - const at = Date.now(); - wipeState.markLoginRefused(ALICE, at); - - await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS - 1000); - expect(heard).toEqual([]); - expect(purge.files).toBe(0); - - state.clearEvicted(ALICE); - await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS + 1000); - expect(heard).toEqual([]); - expect(purge.files).toBe(0); - expect(wipeState.readRefusedLogins()).toEqual({}); - - state.markEvicted(ALICE); - wipeState.markLoginRefused(ALICE, at); - wipe.registerDeviceWipe(); - await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS + 1000); - expect(heard).toEqual([ALICE]); - expect(purge.files).toBeGreaterThan(0); - expect(state.useOfflineStore.getState().enabled).toEqual([]); - expect(wipeState.readRefusedLogins()).toEqual({}); + it('the session limit stays out of it: no hook, and the data waits', async () => { + wipe.registerDeviceWipe(); + token(() => json(401, { error: 'session_expired', maxHours: 8 })); + await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); + expect(heard).toEqual([]); + await vi.waitFor(() => expect(state.isEvicted(ALICE)).toBe(true)); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); }); }); describe('other windows', () => { - it('a window showing the wiped account reloads, one showing another stays, and the wiping window keeps going', async () => { + it('a window showing the wiped account reloads at the end, and only then', async () => { const reload = vi.fn(); vi.stubGlobal('location', { ...window.location, reload }); const stop = wipe.startDeviceWipe(); const other = new BroadcastChannel('bulwark-device-wipe'); - other.postMessage({ account: BOB }); + other.postMessage({ account: ALICE, phase: 'start' }); await new Promise((resolve) => setTimeout(resolve, 30)); expect(reload).not.toHaveBeenCalled(); - - other.postMessage({ account: ALICE }); + other.postMessage({ account: ALICE, phase: 'end' }); await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); other.close(); @@ -515,6 +712,44 @@ describe('other windows', () => { vi.stubGlobal('fetch', vi.fn(async () => noWipe())); }); + it.each(['start', 'end'])('a window showing another account lets go of the wiped one at the %s, in place', async (phase) => { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + const stop = wipe.startDeviceWipe(); + const other = new BroadcastChannel('bulwark-device-wipe'); + + other.postMessage({ account: BOB, phase }); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined()); + + // Its own registry won't write it back, and the wiping window's work isn't done over here. + expect(reload).not.toHaveBeenCalled(); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(state.isEvicted(BOB)).toBe(false); + expect(store.removed).toEqual([]); + expect(purge.files).toBe(0); + expect(JSON.parse(localStorage.getItem('account-registry') ?? '{"state":{"accounts":[]}}').state.accounts.map((a: { id: string }) => a.id)).not.toContain(BOB); + + other.close(); + stop(); + vi.unstubAllGlobals(); + vi.stubGlobal('fetch', vi.fn(async () => noWipe())); + }); + + it('a wipe tells the others when it starts and when it ends', async () => { + const heard: unknown[] = []; + const other = new BroadcastChannel('bulwark-device-wipe'); + other.onmessage = (event) => heard.push(event.data); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + purge.during = () => { heard.push('deleting'); }; + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + await vi.waitFor(() => expect(heard.at(-1)).toEqual({ account: ALICE, phase: 'end' })); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(heard).toContainEqual({ account: ALICE, phase: 'start' }); + other.close(); + }); + it('the window that wipes doesn\'t hear its own message', async () => { const reload = vi.fn(); vi.stubGlobal('location', { ...window.location, reload }); @@ -591,3 +826,295 @@ describe('the wipe from settings', () => { expect(sessionStorage.getItem('signed_out')).toBe('true'); }); }); + +describe('the report and the deletion', () => { + beforeEach(() => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + }); + + it('is queued before anything is deleted, and not sent until it\'s done', async () => { + let queuedWhenDeleting: string[] = []; + let sentWhenDeleting = -1; + purge.during = () => { + // The first time is the wipe's. The sign-out purges too, when all is done. + if (sentWhenDeleting >= 0) return; + queuedWhenDeleting = wipeState.readWipeConfirmations().map((entry) => entry.id); + sentWhenDeleting = sent.filter((call) => call.url.endsWith('/wiped')).length; + }; + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(queuedWhenDeleting).toEqual([ROW]); + expect(sentWhenDeleting).toBe(0); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(1); + expect(wipeState.readWipeConfirmations()).toEqual([]); + }); + + it('another contact during the wipe doesn\'t send it early', async () => { + let early = -1; + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + registry.onDeviceWipe(async () => { + await wipe.checkInDue(true); + early = sent.filter((call) => call.url.endsWith('/wiped')).length; + }); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(early).toBe(0); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(1); + }); + + it('a deletion that fails isn\'t reported as done: it stays pending and is finished at the next start', async () => { + purge.fail = true; + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({ [ALICE]: 'admin' }); + expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]); + // Signed out all the same: nothing more is shown. + expect(useAccountStore.getState().accounts).toEqual([]); + + purge.fail = false; + await wipe.finishInterruptedWipes(); + expect(purge.files).toBeGreaterThan(0); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ id: ROW, reason: 'admin' }); + }); + + it('a plugin database a frame holds open isn\'t reported as wiped until it\'s gone', async () => { + const held = await new Promise((resolve, reject) => { + const request = indexedDB.open('smime-plugin-store', 1); + request.onupgradeneeded = () => { request.result.createObjectStore('s'); }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(Object.keys(wipeState.readBlockedDatabases())).toEqual(['smime-plugin-store']); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]); + + held.close(); + await wipe.finishInterruptedWipes(); + await wipe.checkInDue(true); + await vi.waitFor(() => expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(1)); + expect(wipeState.readBlockedDatabases()).toEqual({}); + expect(await databases()).not.toContain('smime-plugin-store'); + }); +}); + +describe('two wipes at once', () => { + beforeEach(async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + respond = () => json(200, { ok: true }); + }); + + it('neither puts back the row the other just removed', async () => { + await Promise.all([wipe.wipeAccountFromDevice(ALICE, 'admin'), wipe.wipeAccountFromDevice(BOB, 'admin')]); + expect(await keys.listDeviceRecords()).toEqual([]); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(sent.filter((call) => call.url.endsWith('/wiped')).map((call) => call.body.id).sort()).toEqual([ROW, BOB_ROW].sort()); + }); + + it('one runs after the other: they don\'t overlap', async () => { + const running: string[] = []; + let overlapped = false; + registry.onDeviceWipe(async (account) => { + if (running.length > 0) overlapped = true; + running.push(String(account)); + await new Promise((resolve) => setTimeout(resolve, 20)); + running.pop(); + }); + await Promise.all([wipe.wipeAccountFromDevice(ALICE, 'admin'), wipe.wipeAccountFromDevice(BOB, 'admin')]); + expect(overlapped).toBe(false); + }); + + it('a wipe heard twice is done once: the second finds the row gone', async () => { + await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW }); + const reports = sent.filter((call) => call.url.endsWith('/wiped')).length; + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0 }); + await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW }); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(reports); + expect(wipeState.consumeWipeNote()).toBeNull(); + expect(wipeState.readPendingWipes()).toEqual({}); + }); + + it('takes the Web Lock where the browser has one', async () => { + const names: string[] = []; + vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { + locks: { request: async (name: string, work: () => Promise) => { names.push(name); return work(); } }, + })); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + vi.stubGlobal('navigator', Object.getPrototypeOf(navigator)); + expect(names).toContain('bulwark-device-wipe'); + }); +}); + +describe('a sign-out', () => { + beforeEach(() => { + respond = () => json(200, { ok: true }); + }); + + it('drops the row with its secret', async () => { + wipe.registerDeviceWipe(); + await useAuthStore.getState().logout({ endProviderSession: false }); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toEqual({ id: ROW, secret: SECRET, drop: true })); + expect(wipeState.readWipeConfirmations()).toEqual([]); + }); + + it('removing another account drops its row too, and keeps this one\'s', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + wipe.registerDeviceWipe(); + useAuthStore.getState().removeAccount(BOB); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toEqual({ id: BOB_ROW, secret: SECRET, drop: true })); + expect(await keys.getDeviceRecord(BOB)).toBeNull(); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW); + }); + + it('a sign-out that keeps the data keeps the row: an admin can still reach it', async () => { + wipe.registerDeviceWipe(); + await useAuthStore.getState().logout({ endProviderSession: false, keepLocalData: true }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW); + }); + + it('gets through at the next contact when the instance was out of reach', async () => { + respond = () => json(503, {}); + wipe.registerDeviceWipe(); + await useAuthStore.getState().logout({ endProviderSession: false }); + await vi.waitFor(() => expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW])); + respond = () => json(200, { ok: true }); + await wipe.checkInDue(true); + await vi.waitFor(() => expect(wipeState.readWipeConfirmations()).toEqual([])); + }); +}); + +describe('counting what a wipe loses', () => { + const offline = () => import('../offline-mode'); + + it('per account: each row reports its own, not the device\'s total', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + await queue.queueSend(ALICE, { subject: 'One', to: [] }, { kind: 'email', args: [] }); + await queue.queueSend(ALICE, { subject: 'Two', to: [] }, { kind: 'email', args: [] }); + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + const reports = sent.filter((call) => call.url.endsWith('/wiped')).map((call) => call.body); + expect(reports.find((body) => body.id === ROW)).toMatchObject({ unsent: 2 }); + expect(reports.find((body) => body.id === BOB_ROW)).toMatchObject({ unsent: 0 }); + }); + + it('a store that can\'t answer gives unknown, not nought', async () => { + store.failArea = () => true; + expect(await (await offline()).unsentForWipe(ALICE)).toBeNull(); + }); + + it('a store that doesn\'t answer in time gives unknown, not nought', async () => { + const api = await import('@/lib/store/client/api'); + vi.spyOn(api.getStore(), 'area').mockImplementation(() => new Promise(() => {})); + expect(await (await offline()).unsentForWipe(ALICE, 20)).toBeNull(); + vi.restoreAllMocks(); + }); + + it('waits longer for it than a sign-out does', async () => { + expect((await offline()).UNSENT_FOR_WIPE_MS).toBeGreaterThanOrEqual(10_000); + }); + + it('reports an unknown count as such, and says so at the next sign-in', async () => { + store.failArea = () => true; + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ unsent: null }); + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0, unknown: true }); + }); + + it('counts an account nobody can see too: it goes with the device', async () => { + await queue.queueSend(ALICE, { subject: 'Hidden', to: [] }, { kind: 'email', args: [] }); + state.markEvicted(ALICE); + expect(await (await offline()).unsentForWipe('all')).toBe(1); + }); +}); + +describe('what else goes with a wipe', () => { + const ACCOUNT_KEYS = [ + 'pro-tabs', 'search-history-storage', 'files-favorites', 'files-recent-files', 'files-last-parent-id', 'files-path-stack', + 'bulwark-delegate-names', 'calendar-notification-storage', 'identity-storage', 'contact-storage', 'calendar-storage', + 'webdav-favorites', 'webdav-recent-files', 'webdav-last-path', 'template-storage', + ]; + let closed: string[]; + let unsubscribed: number; + let deletedCaches: string[]; + + function plant(): void { + for (const key of ACCOUNT_KEYS) localStorage.setItem(key, 'x'); + localStorage.setItem('settings-storage', 'device preference'); + localStorage.setItem('theme-storage', 'device preference'); + localStorage.setItem('email-snapshot', JSON.stringify({ account: ALICE, emails: [{ subject: 'From Eve' }] })); + sessionStorage.setItem('bulwark-rescued-drafts', JSON.stringify([{ subject: 'Half written', body: 'text' }])); + } + + beforeEach(() => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + closed = []; + unsubscribed = 0; + deletedCaches = []; + plant(); + const registration = { + getNotifications: async () => [{ title: 'From Eve', close() { closed.push(this.title); } }], + pushManager: { getSubscription: async () => ({ unsubscribe: async () => { unsubscribed += 1; return true; } }) }, + }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { getRegistration: async () => registration, controller: null } }); + vi.stubGlobal('caches', { + keys: async () => ['bulwark-push-state-v1', 'unrelated'], + delete: async (name: string) => { deletedCaches.push(name); return true; }, + open: async () => ({ put: async () => {} }), + }); + }); + + afterEach(() => { + delete (navigator as { serviceWorker?: unknown }).serviceWorker; + vi.unstubAllGlobals(); + }); + + it('an admin wipe takes them, closes what\'s on screen, and the last account ends Web Push', async () => { + const { saveForwardApprovals, forwardApprovals } = await import('@/lib/plugin-sandbox/forward-approvals'); + saveForwardApprovals('forwarder', ALICE, ['eve@example.org']); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + for (const key of ACCOUNT_KEYS) expect([key, localStorage.getItem(key)]).toEqual([key, null]); + expect(localStorage.getItem('email-snapshot')).toBeNull(); + expect(sessionStorage.getItem('bulwark-rescued-drafts')).toBeNull(); + expect(forwardApprovals('forwarder', ALICE)).toEqual([]); + expect(closed).toEqual(['From Eve']); + expect(deletedCaches).toContain('bulwark-push-state-v1'); + expect(unsubscribed).toBe(1); + expect(localStorage.getItem('settings-storage')).toBe('device preference'); + expect(localStorage.getItem('theme-storage')).toBe('device preference'); + }); + + it('with another account left, Web Push stays: it\'s shared', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(closed).toEqual(['From Eve']); + expect(unsubscribed).toBe(0); + }); + + it('a wipe from settings takes them too', async () => { + await wipe.wipeWholeDevice('user'); + for (const key of ACCOUNT_KEYS) expect([key, localStorage.getItem(key)]).toEqual([key, null]); + expect(unsubscribed).toBe(1); + }); + + it('a deletion after a refused login leaves all of it: only the account\'s own snapshot goes', async () => { + await policyAsksForDeletion(); + wipe.registerDeviceWipe(); + refuse(); + await vi.waitFor(() => expect(store.removed.length + purge.files).toBeGreaterThan(0)); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + expect(localStorage.getItem('email-snapshot')).toBeNull(); + for (const key of ACCOUNT_KEYS) expect([key, localStorage.getItem(key)]).toEqual([key, 'x']); + expect(closed).toEqual([]); + expect(unsubscribed).toBe(0); + }); +}); diff --git a/lib/offline/__tests__/evicted-secrets.test.ts b/lib/offline/__tests__/evicted-secrets.test.ts new file mode 100644 index 000000000..8bfbbd8c2 --- /dev/null +++ b/lib/offline/__tests__/evicted-secrets.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// A dropped account's plugin secrets, on the desktop app's own server. It has no +// cookie left, so what the account store wrote down at the time is all there is. + +const ALICE = 'alice@mail.example.test'; +const entry = { + id: ALICE, label: ALICE, serverUrl: 'https://mail.example.test', username: 'alice', authMode: 'basic', rememberMe: true, + displayName: ALICE, email: ALICE, lastLoginAt: 1, isConnected: true, hasError: false, isDefault: true, cookieSlot: 0, avatarColor: '#000', +}; + +async function load(desktop: boolean) { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', desktop ? '1' : ''); + const { useAccountStore } = await import('@/stores/account-store'); + const state = await import('../state'); + const { forgetEvictedSecrets } = await import('../evicted-secrets'); + useAccountStore.setState({ accounts: [entry], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + state.writeEnabled([ALICE]); + return { useAccountStore, state, forgetEvictedSecrets }; +} + +let calls: Array<{ url: string; method?: string }>; +let answer: () => Response; + +beforeEach(() => { + localStorage.clear(); + calls = []; + answer = () => new Response('{"ok":true}', { status: 200 }); + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => { + calls.push({ url: String(input), method: init?.method }); + return answer(); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('a dropped account\'s plugin secrets', () => { + it('in the desktop app go by who it was, with no cookie', async () => { + const { useAccountStore, state, forgetEvictedSecrets } = await load(true); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + expect(state.readEvictedOwners()[ALICE]).toEqual({ username: 'alice', serverUrl: 'https://mail.example.test' }); + expect(JSON.stringify(state.readEvictedOwners())).not.toContain('password'); + + await forgetEvictedSecrets(ALICE); + expect(calls).toHaveLength(1); + const url = new URL(calls[0].url, 'https://desktop.local'); + expect(calls[0].method).toBe('DELETE'); + expect(url.pathname).toBe('/api/plugin-secrets'); + expect(Object.fromEntries(url.searchParams)).toEqual({ all: 'true', username: 'alice', server: 'https://mail.example.test' }); + expect(state.readEvictedOwners()).toEqual({}); + }); + + it('are asked for again when the server couldn\'t', async () => { + const { useAccountStore, state, forgetEvictedSecrets } = await load(true); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + answer = () => new Response('{}', { status: 503 }); + await forgetEvictedSecrets(ALICE); + expect(Object.keys(state.readEvictedOwners())).toEqual([ALICE]); + }); + + it('stay when the account signs in again, and nobody remembers it was dropped', async () => { + const { useAccountStore, state, forgetEvictedSecrets } = await load(true); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + useAccountStore.getState().addAccount(entry as never); + expect(state.readEvictedOwners()).toEqual({}); + await forgetEvictedSecrets(null); + expect(calls).toEqual([]); + }); + + it('on an instance aren\'t this device\'s: nothing is written down or asked', async () => { + const { useAccountStore, state, forgetEvictedSecrets } = await load(false); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + expect(state.readEvictedOwners()).toEqual({}); + await forgetEvictedSecrets(null); + expect(calls).toEqual([]); + }); +}); diff --git a/lib/offline/credentials-rejected.ts b/lib/offline/credentials-rejected.ts index 29c5c6015..8a4162723 100644 --- a/lib/offline/credentials-rejected.ts +++ b/lib/offline/credentials-rejected.ts @@ -1,12 +1,14 @@ /** - * The server really refused this login (`invalid_grant`), which the auth store - * follows with a sign-out that wipes the account's local data. An outage or any - * other refusal doesn't come here, and keeps the data. lib/offline/device-wipe.ts - * registers here for everything beyond the local store. Free of the stores, so - * the auth store can call it. + * The server refused this login: `invalid_grant`, a lasting 401 after a fresh try + * (both `certain`), or a 401 at a connect that then failed (not, it's one answer). + * The auth store follows with a sign-out that hides the account's data, which + * waits for the new password. The organisation's session limit, an outage or any + * other refusal doesn't come here. lib/offline/device-wipe.ts listens, to keep the + * count for the sign-in page and to delete at once where the organisation asks for + * that. Free of the stores, so the auth store can call it. */ -type Listener = (accountId: string) => void; +type Listener = (accountId: string, certain: boolean) => void; const listeners = new Set(); @@ -15,10 +17,10 @@ export function onCredentialsRejected(listener: Listener): () => void { return () => listeners.delete(listener); } -export function noteCredentialsRejected(accountId: string): void { +export function noteCredentialsRejected(accountId: string, certain = true): void { for (const listener of [...listeners]) { try { - listener(accountId); + listener(accountId, certain); } catch { // A sign-out must go on whatever a listener does. } diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index abf524982..a1989aa44 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -4,9 +4,16 @@ * * - the instance's registry (lib/devices): a wipe an admin asked for, heard at * check-in. At a start the check-in runs before the local copy is shown. - * - the server refusing the stored login for good, through - * lib/offline/credentials-rejected.ts. * - the user, from settings. + * - the organisation, when it asks for deletion at once after a refused login + * (`organisation.devices.wipeOnRefusedLogin`). + * + * A refused login on its own deletes nothing. The account signs out and its data + * hides until someone signs in with the new password, or for 30 days + * (EVICTED_KEEP_MS). What this file keeps for it is a number, for the sign-in page. + * The usual cause is a password changed on another device, and deleting there + * would lose unsent mail for very little protection against a thief. A deletion + * the organisation asked for takes the account's own data and nothing shared. * * Only a clear word wipes. An outage, a 500, an unknown answer: nothing happens. * @@ -24,6 +31,10 @@ * sealed one lasts 30 days, but a check-in only needs the secret, so a device * still hears its wipe. One that lost its key database has nothing left to wipe * and can't be told. Its row keeps saying the wipe is waiting. + * + * Wipes run one at a time across every window of this browser, under a Web Lock: + * the last account's wipe deletes the key database with every row in it, so a + * second wipe going on at that moment would put back a row just removed. */ import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; @@ -32,13 +43,19 @@ import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugi import { fileStorage } from '@/lib/plugin-storage'; import { clearHandoffs } from '@/lib/launch-handoff'; import { apiFetch } from '@/lib/browser-navigation'; +import { SW_SCOPE } from '@/lib/app-update'; +import { devicesForAccount, devicesForNoOne } from '@/lib/device-policy'; import { getDesktopBridge } from '@/lib/desktop/bridge'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { clearRescuedDrafts } from '@/lib/popout/rescued-drafts'; import { isPopoutWindow } from '@/lib/popout/window-role'; +import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; +import { ACCOUNT_STORAGE_KEYS, onBeforeSignOut } from '@/lib/sign-out-cleanup'; import { checkInWithInstance, registerWithInstance, reportWipeToInstance, + type RegisteredRow, } from '@/lib/devices/client'; import type { WipeReason } from '@/lib/devices/types'; import { @@ -52,7 +69,7 @@ import { readPendingWipes, readRefusedLogins, readWipeConfirmations, - REFUSED_KEEP_MS, + setRefusedCount, WHOLE_DEVICE, writeBlockedDatabases, } from '@/lib/devices/wipe-state'; @@ -63,11 +80,13 @@ import { putDeviceRecord, type DeviceRecord, } from '@/lib/store/client/keys'; -import { useAccountStore } from '@/stores/account-store'; +import { onAccountRemoved, useAccountStore, type AccountEntry } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; -import { noteCredentialsRejected, onCredentialsRejected } from './credentials-rejected'; -import { confirmLoss, registerOfflineHooks, unsentCount, wipeAccount, wipeEverything } from './offline-mode'; -import { clearEvicted, isEvicted, readEvicted, useOfflineStore } from './state'; +import { usePolicyStore } from '@/stores/policy-store'; +import { onCredentialsRejected } from './credentials-rejected'; +import { forgetEvictedSecrets } from './evicted-secrets'; +import { confirmLoss, registerOfflineHooks, unsentForWipe, wipeAccount, wipeEverything } from './offline-mode'; +import { clearEvicted, forgetMailSnapshot, isEvicted, readEvicted, serverKey, useOfflineStore } from './state'; import { runWipeCleaners } from './wipe-registry'; /** How long a start holds the local copy back for the instance's answer. Offline, it doesn't wait at all. */ @@ -80,9 +99,9 @@ const TICK_MS = 60 * 1000; const REPORT_BUDGET_MS = 3_000; /** How long a wipe waits for one database a running plugin frame holds open. */ const DATABASE_BUDGET_MS = 2_000; -const DATABASE_RETRIES = 5; const WIPE_CHANNEL = 'bulwark-device-wipe'; +const WIPE_LOCK = 'bulwark-device-wipe'; /** The webmail's own databases. Any other on this origin was made by a privileged plugin. */ const APP_DATABASES = new Set([ @@ -95,11 +114,37 @@ const APP_DATABASES = new Set([ /** Deleted by name too, for browsers without `indexedDB.databases()`. */ const KNOWN_PLUGIN_DATABASES = ['smime-plugin-store', 'bulwark-openpgp', 'smime-capability-probe', 'openpgp-capability-probe']; -/** What every account shared and can't be told apart by account. */ -const SHARED_LOCAL_KEYS = ['notification-center', 'plugin-oauth-states', 'webmail_usernames', 'global-search', 'favicon-refresh']; +/** + * What every account shared and can't be told apart by account, on top of what a + * full sign-out removes. `template-storage` is here though a sign-out keeps it: + * with settings sync off it's the user's own work and only here, which is why a + * sign-out leaves it, but a template can hold anything, and a device told to remove + * everything keeps no text. The question before a wipe from settings names it. + */ +const SHARED_LOCAL_KEYS = [ + ...ACCOUNT_STORAGE_KEYS, + 'notification-center', + 'plugin-oauth-states', + 'webmail_usernames', + 'global-search', + 'favicon-refresh', + 'template-storage', +]; +/** The WebDAV area's favourites, recent files and last folder. */ +const SHARED_LOCAL_PREFIXES = ['webdav-']; const running = new Map>(); +let lockQueue: Promise = Promise.resolve(); + +/** One wipe at a time. Without Web Locks: at least in this window. */ +function underWipeLock(work: () => Promise): Promise { + if (typeof navigator !== 'undefined' && navigator.locks) return navigator.locks.request(WIPE_LOCK, work); + const turn = lockQueue.then(work, work); + lockQueue = turn.catch(() => {}); + return turn; +} + // ---- Removing it ---- function deleteDatabase(name: string): Promise { @@ -125,10 +170,13 @@ function deleteDatabase(name: string): Promise { * Plugin-owned databases. S/MIME and OpenPGP keys aren't kept per account, so a * wipe of one account takes everyone's: keeping them would leave a wiped user's * private key behind. They're encrypted and come back from the user's own backup. + * One a running frame holds open stays on the list, and the wipe isn't reported + * as done until it's gone. */ async function deletePluginDatabases(): Promise { if (typeof indexedDB === 'undefined') return; - const names = new Set([...KNOWN_PLUGIN_DATABASES, ...Object.keys(readBlockedDatabases())]); + const tries = readBlockedDatabases(); + const names = new Set([...KNOWN_PLUGIN_DATABASES, ...Object.keys(tries)]); try { for (const { name } of await indexedDB.databases()) { if (name && !APP_DATABASES.has(name)) names.add(name); @@ -136,12 +184,9 @@ async function deletePluginDatabases(): Promise { } catch { // No list in this browser: the names above. } - const tries = readBlockedDatabases(); const blocked: Record = {}; await Promise.all([...names].map(async (name) => { - if (await deleteDatabase(name)) return; - const count = (tries[name] ?? 0) + 1; - if (count < DATABASE_RETRIES) blocked[name] = count; + if (!(await deleteDatabase(name))) blocked[name] = (tries[name] ?? 0) + 1; })); writeBlockedDatabases(blocked); } @@ -175,6 +220,8 @@ async function forgetPluginAccounts(account: string | null): Promise { headers: { 'X-JMAP-Cookie-Slot': String(slot) }, signal: AbortSignal.timeout(REPORT_BUDGET_MS), }).catch(() => {}))); + // An account dropped earlier has no cookie left to ask with. + await forgetEvictedSecrets(account); } async function forgetPluginSettings(): Promise { @@ -182,25 +229,51 @@ async function forgetPluginSettings(): Promise { usePluginStore.setState((state) => ({ plugins: state.plugins.map((plugin) => ({ ...plugin, settings: {} })) })); } -function removeLocalKeys(keys: string[]): void { - for (const key of keys) { - try { - localStorage.removeItem(key); - } catch { - /* storage unavailable */ +function removeLocalKeys(keys: string[], prefixes: string[]): void { + try { + const doomed = [...keys]; + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index); + if (key && prefixes.some((prefix) => key.startsWith(prefix))) doomed.push(key); } + for (const key of doomed) localStorage.removeItem(key); + } catch { + /* storage unavailable */ } } -/** Everything else that's the account's or that accounts share. `account` null: all of it. */ -async function scrub(account: string | null): Promise { +async function pushRegistration(): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return null; + return (await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null)) ?? null; +} + +/** A sender and a subject can sit on screen long after the mail is gone. */ +async function closeShownNotifications(): Promise { + const registration = await pushRegistration(); + for (const notification of await registration?.getNotifications?.() ?? []) notification.close(); +} + +/** The subscription is the browser's, shared by every account on this origin. */ +async function endWebPushIfNobodyLeft(): Promise { + if (useAccountStore.getState().accounts.length > 0) return; + const registration = await pushRegistration(); + const subscription = await registration?.pushManager?.getSubscription(); + await subscription?.unsubscribe(); +} + +/** What's the account's own: a deletion after a refused login goes no further than this. */ +async function scrubAccount(account: string): Promise { await runWipeCleaners(account); + await forgetPluginAccounts(account).catch(() => {}); + clearPluginStorageForAccount(account); + forgetMailSnapshot(account); +} + +/** Everything accounts share, or that can't be told apart by account. */ +async function scrubShared(): Promise { await clearShellFiles().catch(() => {}); await deletePluginDatabases(); - await forgetPluginAccounts(account); await forgetPluginSettings().catch(() => {}); - if (account === null) clearAllPluginStorage(); - else clearPluginStorageForAccount(account); await fileStorage.clearFiles().catch(() => {}); await clearAllCheckpoints().catch(() => {}); await sweepPrivateExports({ force: true }).catch(() => {}); @@ -210,13 +283,30 @@ async function scrub(account: string | null): Promise { /* storage unavailable */ } clearHandoffs(); + clearRescuedDrafts(); const { useNotificationCenterStore } = await import('@/stores/notification-center-store'); useNotificationCenterStore.getState().clear(); - removeLocalKeys(SHARED_LOCAL_KEYS); + await closeShownNotifications().catch(() => {}); + if (typeof caches !== 'undefined') await caches.delete(PUSH_STATE_CACHE).catch(() => {}); + removeLocalKeys(SHARED_LOCAL_KEYS, SHARED_LOCAL_PREFIXES); } -async function flushConfirmations(): Promise { - await Promise.all(readWipeConfirmations().map(async (entry) => { +/** + * Sends what's ready to go. A wipe's confirmation is queued before anything is + * deleted, so that a crash can't lose it, and isn't sent until the deletion is + * done: `finished` is the wipe that just got there. What's still pending, or a + * plugin database that's still there, isn't a wipe that can be reported. + */ +async function flushConfirmations(finished?: string): Promise { + const pending = readPendingWipes(); + const blocked = Object.keys(readBlockedDatabases()).length > 0; + const deviceOpen = WHOLE_DEVICE in pending && finished !== WHOLE_DEVICE; + const ready = readWipeConfirmations().filter((entry) => { + if (entry.drop) return true; + if (deviceOpen || (blocked && entry.reason !== 'rejected')) return false; + return !entry.account || !(entry.account in pending) || entry.account === finished; + }); + await Promise.all(ready.map(async (entry) => { if (await reportWipeToInstance(entry, AbortSignal.timeout(REPORT_BUDGET_MS))) dropWipeConfirmation(entry.id); })); } @@ -242,8 +332,10 @@ function otherWindows(): BroadcastChannel | null { } } -function tellOtherWindows(account: string | null): void { - otherWindows()?.postMessage({ account }); +type WipePhase = 'start' | 'end'; + +function tellOtherWindows(account: string | null, phase: WipePhase): void { + otherWindows()?.postMessage({ account, phase }); } async function signOutAccount(account: string): Promise { @@ -253,72 +345,137 @@ async function signOutAccount(account: string): Promise { else auth.removeAccount(account, 'explicit'); } +/** The key database goes with the last account's store, and the other accounts' rows with it. */ +async function putRowsBack(rows: DeviceRecord[], wiped: string): Promise { + const still = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + for (const row of rows) { + if (row.account !== wiped && !still.some((entry) => entry.account === row.account)) await putDeviceRecord(row).catch(() => {}); + } +} + +export interface WipeAccountOptions { + /** Heard for this row: another window that got there first has removed it, and there's nothing to do. */ + ifRow?: string; +} + /** - * Takes one account off this device and signs it out. A refused login's sign-out - * is the auth store's own, already on its way, and its lost mail is named as for - * any sign-out nobody asked for. Everywhere else lost mail is a count. + * Takes one account off this device. A refused login's sign-out is the auth + * store's own, and what it deletes is only the account's: no shared key database, + * no plugin settings, and with nothing kept here nothing of offline mode either. */ -export function wipeAccountFromDevice(account: string, reason: WipeReason): Promise { +export function wipeAccountFromDevice(account: string, reason: WipeReason, options: WipeAccountOptions = {}): Promise { const under = running.get(account); if (under) return under; - const work = eraseAccount(account, reason).finally(() => running.delete(account)); + // Now, not when the lock comes: a window closed while it waits still finishes this. + markWipePending(account, reason); + const work = underWipeLock(() => eraseAccount(account, reason, options)).finally(() => running.delete(account)); running.set(account, work); return work; } -async function eraseAccount(account: string, reason: WipeReason): Promise { - markWipePending(account, reason); +async function eraseAccount(account: string, reason: WipeReason, options: WipeAccountOptions): Promise { + // Another window finished it while this one waited for the lock. + if (!(account in readPendingWipes())) return; const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); const record = records.find((entry) => entry.account === account); - const named = reason === 'rejected'; - const unsent = named ? 0 : (await unsentCount(account).catch(() => ({ messages: 0 }))).messages; + if (options.ifRow && record?.id !== options.ifRow) { + clearWipePending(account); + return; + } + tellOtherWindows(account, 'start'); registerOfflineHooks(); - if (!named) confirmLoss(account); - await wipeAccount(account).catch(() => {}); - clearEvicted(account); - dropRefusedLogin(account); - await scrub(account).catch(() => {}); - // The last account's wipe deletes the whole key database, with the rows of accounts that stay. - await deleteDeviceRecord(account).catch(() => {}); - for (const other of records) { - if (other.account !== account) await putDeviceRecord(other).catch(() => {}); + const ownOnly = reason === 'rejected'; + const unsent = await unsentForWipe(account); + if (record && !readWipeConfirmations().some((entry) => entry.id === record.id)) { + queueWipeConfirmation({ account, id: record.id, secret: record.secret, reason, unsent }); + if (reason === 'admin') addWipeNote(unsent); } - if (record) queueWipeConfirmation({ id: record.id, secret: record.secret, reason, unsent }); - if (reason === 'admin') addWipeNote(unsent); - await flushConfirmations(); - if (!named) await signOutAccount(account); - clearWipePending(account); - tellOtherWindows(account); + const shown = useOfflineStore.getState(); + const keepsMail = shown.enabled.includes(account) || shown.outbox.some((row) => row.account === account) || shown.localDrafts.some((row) => row.account === account); + if (!ownOnly) confirmLoss(account); + let deleted = true; + if (!ownOnly || keepsMail) { + // Tried again at the next start: the row keeps waiting until then. + await wipeAccount(account).catch(() => { deleted = false; }); + } + clearEvicted(account); + dropRefusedLogin(account); + await scrubAccount(account).catch(() => {}); + if (!ownOnly) await scrubShared().catch(() => {}); + + await deleteDeviceRecord(account).catch(() => {}); + await putRowsBack(records, account); + + await flushConfirmations(deleted ? account : undefined); + if (!ownOnly) { + await signOutAccount(account); + await endWebPushIfNobodyLeft().catch(() => {}); + } + if (deleted) clearWipePending(account); + tellOtherWindows(account, 'end'); } /** Everything on this device, for everyone. The app's device preferences stay: they hold no mail. */ export function wipeWholeDevice(reason: WipeReason): Promise { const under = running.get(WHOLE_DEVICE); if (under) return under; - const work = eraseDevice(reason).finally(() => running.delete(WHOLE_DEVICE)); + markWipePending(WHOLE_DEVICE, reason); + const work = underWipeLock(() => eraseDevice(reason)).finally(() => running.delete(WHOLE_DEVICE)); running.set(WHOLE_DEVICE, work); return work; } async function eraseDevice(reason: WipeReason): Promise { - markWipePending(WHOLE_DEVICE, reason); - const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); - const unsent = (await unsentCount('all').catch(() => ({ messages: 0 }))).messages; + if (!(WHOLE_DEVICE in readPendingWipes())) return; + tellOtherWindows(null, 'start'); registerOfflineHooks(); + const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + const accounts = [...new Set([...useOfflineStore.getState().enabled, ...records.map((record) => record.account)])]; + const counts = new Map(await Promise.all(accounts.map(async (account) => [account, await unsentForWipe(account)] as const))); + const queued = readWipeConfirmations(); + for (const record of records) { + if (queued.some((entry) => entry.id === record.id)) continue; + queueWipeConfirmation({ account: record.account, id: record.id, secret: record.secret, reason, unsent: counts.get(record.account) ?? 0 }); + } + if (reason === 'admin') { + const each = [...counts.values()]; + addWipeNote(each.includes(null) ? null : (each as number[]).reduce((sum, count) => sum + count, 0)); + } + confirmLoss('all'); - await scrub(null).catch(() => {}); - await wipeEverything().catch(() => {}); + await runWipeCleaners(null); + await forgetPluginAccounts(null).catch(() => {}); + clearAllPluginStorage(); + await scrubShared().catch(() => {}); + let deleted = true; + await wipeEverything().catch(() => { deleted = false; }); for (const account of Object.keys(readEvicted())) clearEvicted(account); for (const account of Object.keys(readRefusedLogins())) dropRefusedLogin(account); - for (const record of records) queueWipeConfirmation({ id: record.id, secret: record.secret, reason, unsent }); - if (reason === 'admin') addWipeNote(unsent); - await flushConfirmations(); + await flushConfirmations(deleted ? WHOLE_DEVICE : undefined); signalSignedOut(); await useAuthStore.getState().logoutAll({ endProviderSession: false }); - clearWipePending(WHOLE_DEVICE); - tellOtherWindows(null); + await endWebPushIfNobodyLeft().catch(() => {}); + if (deleted) clearWipePending(WHOLE_DEVICE); + tellOtherWindows(null, 'end'); +} + +// ---- A sign-out: the row has nothing left to look after ---- + +function queueDrop(row: Pick): void { + queueWipeConfirmation({ id: row.id, secret: row.secret, reason: 'user', unsent: 0, drop: true }); +} + +async function dropRows(scope: string): Promise { + const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + const rows = scope === 'all' ? records : records.filter((record) => record.account === scope); + if (rows.length === 0) return; + for (const row of rows) { + queueDrop(row); + await deleteDeviceRecord(row.account).catch(() => {}); + } + await flushConfirmations(); } // ---- Hearing about it ---- @@ -344,26 +501,51 @@ async function checkInRecord(record: DeviceRecord, now: number): Promise { lastCheckIn.set(record.account, now); const answer = await checkInWithInstance(record, AbortSignal.timeout(REPORT_BUDGET_MS * 3)); if (answer === 'wipe') { - await wipeAccountFromDevice(record.account, 'admin'); + await wipeAccountFromDevice(record.account, 'admin', { ifRow: record.id }); return; } - if (answer === 'forgotten') { + // The row is gone, or wiped already: what it was for is over. The account stays, and gets a new one. + if (answer === 'forgotten' || answer === 'wiped') { await deleteDeviceRecord(record.account).catch(() => {}); + lastRegisterTry.delete(record.account); return; } // Signed out here with nothing kept: the row has nothing left to look after. if (answer === 'clear') { const known = useAccountStore.getState().accounts.some((entry) => entry.id === record.account); - if (!known && !useOfflineStore.getState().enabled.includes(record.account)) await deleteDeviceRecord(record.account).catch(() => {}); + if (!known && !useOfflineStore.getState().enabled.includes(record.account)) await dropRows(record.account); } } -async function registerAccount(accountId: string, slot: number): Promise { +/** + * The cookie slot decides whose row the instance makes. A slot that has moved on + * to another account since would leave this account's mail on someone else's row, + * where a wipe of this account never finds it. A bare name is the same person as a + * whole address: a token sign-in can claim either. + */ +function ownsRow(row: RegisteredRow, account: AccountEntry): boolean { + if (serverKey(row.server) !== serverKey(account.serverUrl)) return false; + const user = row.user.toLowerCase(); + return [account.username, account.email].some((candidate) => { + const name = candidate?.toLowerCase(); + if (!name) return false; + if (name === user) return true; + return (!name.includes('@') || !user.includes('@')) && name.split('@')[0] === user.split('@')[0]; + }); +} + +async function registerAccount(account: AccountEntry): Promise { const register = async () => { // Another window may have got here while this one waited. - if (await getDeviceRecord(accountId)) return; - const row = await registerWithInstance(slot, AbortSignal.timeout(REPORT_BUDGET_MS * 3)); - if (row) await putDeviceRecord({ account: accountId, id: row.id, secret: row.secret, createdAt: Date.now() }); + if (await getDeviceRecord(account.id)) return; + const row = await registerWithInstance(account.cookieSlot ?? 0, AbortSignal.timeout(REPORT_BUDGET_MS * 3)); + if (!row) return; + if (ownsRow(row, account) && useAccountStore.getState().getAccountById(account.id)) { + await putDeviceRecord({ account: account.id, id: row.id, secret: row.secret, createdAt: Date.now() }); + return; + } + queueDrop(row); + await flushConfirmations(); }; if (typeof navigator !== 'undefined' && navigator.locks) await navigator.locks.request('bulwark-device-register', register); else await register(); @@ -393,7 +575,7 @@ async function runCheckIns(force: boolean, now: number): Promise { .filter((account) => force || now - (lastRegisterTry.get(account.id) ?? 0) >= REGISTER_RETRY_MS) .map((account) => { lastRegisterTry.set(account.id, now); - return registerAccount(account.id, account.cookieSlot ?? 0).catch(() => {}); + return registerAccount(account).catch(() => {}); }), ]); } @@ -408,18 +590,46 @@ export async function finishInterruptedWipes(): Promise { await retryBlockedDatabases(); } -/** Refused passwords whose day has passed, with no sign-in since. */ -export async function sweepRefusedLogins(now = Date.now()): Promise { - for (const [account, at] of Object.entries(readRefusedLogins())) { - // Signed in again: the wait is over and nothing goes. +// ---- A refused login ---- + +function policyKnown(): boolean { + const { loaded, partial } = usePolicyStore.getState(); + return loaded && !partial; +} + +/** A number, for the sign-in page. Unknown stays unknown, and is asked again at the next look. */ +async function countRefused(account: string): Promise { + if (!(account in readRefusedLogins())) return; + registerOfflineHooks(); + setRefusedCount(account, await unsentForWipe(account)); +} + +/** + * The login was refused, and the auth store is signing it out and hiding the data. + * It goes now only if the organisation asked for that, and only for a refusal that + * is one for certain: a single bad answer at a start can be a server in trouble. + */ +async function heardRefusal(account: string, certain: boolean): Promise { + if (certain && policyKnown() && devicesForAccount(account).wipeOnRefusedLogin) { + await wipeAccountFromDevice(account, 'rejected'); + return; + } + await countRefused(account); +} + +/** What the sign-in page reads: signed in again, the wait is over. A count that couldn't be had, and a policy that's new. */ +export async function sweepRefusedLogins(): Promise { + for (const [account, wait] of Object.entries(readRefusedLogins())) { if (!isEvicted(account)) { dropRefusedLogin(account); continue; } - if (now - at < REFUSED_KEEP_MS) continue; - dropRefusedLogin(account); - noteCredentialsRejected(account); - await running.get(account); + // Nobody's signed in to it, so no groups to go by: what applies to everyone reaches it. + if (wait.certain && policyKnown() && devicesForNoOne().wipeOnRefusedLogin) { + await wipeAccountFromDevice(account, 'rejected'); + continue; + } + if (wait.unsent === null) await countRefused(account); } } @@ -429,8 +639,14 @@ let registered = false; export function registerDeviceWipe(): void { if (registered) return; registered = true; - onCredentialsRejected((account) => { - void wipeAccountFromDevice(account, 'rejected'); + onCredentialsRejected((account, certain) => { + void heardRefusal(account, certain); + }); + onBeforeSignOut(async (accountId, { keepLocalStore }) => { + if (!keepLocalStore) await dropRows(accountId); + }); + onAccountRemoved((accountId, reason) => { + if (reason === 'explicit') void dropRows(accountId); }); } @@ -450,13 +666,29 @@ export async function runDeviceGate(): Promise { ]); } +/** + * Another window is wiping. An account this one isn't showing leaves its lists at + * once and in place, or the next write of the account registry here brings it back. + * One it's showing: its mail is in this window's memory, so it reloads once the wipe + * is done. Another account's unsaved text isn't touched. + */ +function heardFromOtherWindow(account: string | null, phase: WipePhase): void { + const auth = useAuthStore.getState(); + if (account === null || account === auth.activeAccountId) { + if (phase === 'end') window.location.reload(); + return; + } + if (useAccountStore.getState().getAccountById(account)) auth.removeAccount(account, 'wiped'); + const { enabled } = useOfflineStore.getState(); + if (enabled.includes(account)) useOfflineStore.setState({ enabled: enabled.filter((id) => id !== account) }); +} + function listenToOtherWindows(): () => void { const channel = otherWindows(); if (!channel) return () => {}; - channel.onmessage = (event: MessageEvent<{ account: string | null }>) => { - const { account } = event.data ?? {}; - // Its mail is in this window's memory. Another account's unsaved text isn't touched. - if (account === null || account === useAuthStore.getState().activeAccountId) window.location.reload(); + channel.onmessage = (event: MessageEvent<{ account: string | null; phase?: WipePhase }>) => { + if (event.data?.account === undefined) return; + heardFromOtherWindow(event.data.account, event.data.phase ?? 'end'); }; return () => { channel.onmessage = null; }; } @@ -482,9 +714,13 @@ export function startDeviceWipe(): () => void { for (const account of state.enabled) lastRegisterTry.delete(account); void checkInDue().catch(() => {}); }); + const stopPolicy = usePolicyStore.subscribe((state, prev) => { + if (state.rawPolicy !== prev.rawPolicy || state.partial !== prev.partial) void sweepRefusedLogins().catch(() => {}); + }); return () => { stopOthers(); stopEnabled(); + stopPolicy(); clearInterval(tick); window.removeEventListener('online', onOnline); }; diff --git a/lib/offline/evicted-secrets.ts b/lib/offline/evicted-secrets.ts new file mode 100644 index 000000000..490992645 --- /dev/null +++ b/lib/offline/evicted-secrets.ts @@ -0,0 +1,23 @@ +/** + * Plugin secrets of an account the app dropped, in the desktop app, where they sit + * on this device's own server. The account has no cookie left to ask with, so the + * route takes who it was, from what was written down at the time. On an instance + * they aren't the device's to remove, and nothing is written down. + */ + +import { apiFetch } from '@/lib/browser-navigation'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { dropEvictedOwner, readEvictedOwners } from './state'; + +const BUDGET_MS = 3_000; + +/** `account` null: everyone's. */ +export async function forgetEvictedSecrets(account: string | null): Promise { + if (!IS_DESKTOP_BUILD) return; + const owners = Object.entries(readEvictedOwners()).filter(([id]) => account === null || id === account); + await Promise.all(owners.map(async ([id, owner]) => { + const query = new URLSearchParams({ all: 'true', username: owner.username, server: owner.serverUrl }); + const response = await apiFetch(`/api/plugin-secrets?${query}`, { method: 'DELETE', signal: AbortSignal.timeout(BUDGET_MS) }).catch(() => null); + if (response?.ok) dropEvictedOwner(id); + })); +} diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 6a8c77ac7..f17699d60 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -11,15 +11,15 @@ * - Changes and sends made offline wait in the store's outbox and go out in * order once the server answers again and the organisation's policy was * fetched afresh (flush.ts). - * - Everything is wiped at a full sign-out, at the session limits and when the - * server really refuses the login (`invalid_grant`), by - * lib/sign-out-cleanup.ts; for one account when it signs out, is removed or - * switches offline mode off, and when the organisation forbids it (checked on - * every policy fetch). An account the app drops by itself because it couldn't - * confirm the login (an outage, an unknown refusal, a session cookie that ran - * out) keeps its data for 30 days or the next sign-in, hidden from everyone - * else. Unsent messages a sign-out deletes without the user's word are named - * (lost.ts). + * - Everything is wiped at a full sign-out, by lib/sign-out-cleanup.ts; for one + * account when it signs out, is removed or switches offline mode off, and when + * the organisation forbids it (checked on every policy fetch). An account the + * app drops by itself keeps its data for 30 days or the next sign-in, hidden from + * everyone else: when it couldn't confirm the login (an outage, an unknown + * refusal, a session cookie that ran out), at the organisation's session limit, + * and when the server refuses the login (`invalid_grant`, a lasting 401), unless + * the organisation asks for deletion at once (lib/offline/device-wipe.ts). Unsent + * messages a sign-out deletes without the user's word are named (lost.ts). */ import { IS_LITE } from '@/lib/lite'; @@ -91,6 +91,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from ' import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; +import { forgetEvictedSecrets } from './evicted-secrets'; export type OfflineNotice = | { kind: 'queued'; subject: string } @@ -1025,6 +1026,31 @@ export async function unsentCount(scope: string, waitMs = UNSENT_READ_MS): Promi return Promise.race([read, new Promise((resolve) => setTimeout(() => resolve(known), waitMs))]); } +/** How long a wipe waits for the count. A wipe's report is the only record of what it deleted. */ +export const UNSENT_FOR_WIPE_MS = 10_000; + +async function unsentOfAccount(account: string, waitMs: number): Promise { + const shown = useOfflineStore.getState(); + // Reading an account's store would create it if it never had one. + if (!shown.enabled.includes(account)) return [...shown.outbox, ...shown.localDrafts].filter((row) => row.account === account).length; + let timer: ReturnType | undefined; + const read = Promise.all([readQueue(account), readLocalDrafts(account)]) + .then(([{ failed, outbox: entries }, drafts]) => (failed.length > 0 ? null : entries.length + drafts.length), () => null); + const answer = await Promise.race([read, new Promise((resolve) => { timer = setTimeout(() => resolve(null), waitMs); })]); + clearTimeout(timer); + return answer; +} + +/** + * How many unsent messages a wipe of `scope` deletes, one account or 'all', hidden + * accounts included. Null when the store can't say: that isn't the same as none. + */ +export async function unsentForWipe(scope: string, waitMs = UNSENT_FOR_WIPE_MS): Promise { + const accounts = scope === 'all' ? useOfflineStore.getState().enabled : [scope]; + const counts = await Promise.all(accounts.map((account) => unsentOfAccount(account, waitMs))); + return counts.includes(null) ? null : (counts as number[]).reduce((sum, count) => sum + count, 0); +} + /** Names the unsent messages a wipe of `scope` deletes, unless the user agreed. */ async function nameLost(scope: string, stillSignedIn: boolean): Promise { if (named.has(scope) || named.has('all')) return; @@ -1047,7 +1073,10 @@ export async function wipeAccount(account: string): Promise { if (timer) clearTimeout(timer); syncTimers.delete(account); // Waited for a sign-in that never came: whoever is here now mustn't read its subjects. - if (isEvicted(account)) named.add(account); + if (isEvicted(account)) { + named.add(account); + void forgetEvictedSecrets(account).catch(() => {}); + } const before = useOfflineStore.getState(); if (before.enabled.includes(account) || before.outbox.some((row) => row.account === account) || before.localDrafts.some((row) => row.account === account)) { await nameLost(account, useAuthStore.getState().isAuthenticated); @@ -1348,7 +1377,7 @@ export function startOfflineMode(): () => void { * that left the registry on purpose before this ran (a login without "remember * me" ends at a reload, before the host mounts) goes now, naming lost mail. */ -async function sweepAccountsGone(now = Date.now()): Promise { +export async function sweepAccountsGone(now = Date.now()): Promise { const evicted = readEvicted(); const accounts = useAccountStore.getState().accounts; for (const account of useOfflineStore.getState().enabled) { diff --git a/lib/offline/state.ts b/lib/offline/state.ts index f85726a57..d78392c42 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -254,6 +254,29 @@ export function clearEvicted(account: string): void { writeEvicted(evicted); } +/** localStorage: who a dropped account was, only in the desktop app: its plugin secrets are on this device's server, under this. */ +export const OFFLINE_EVICTED_OWNERS_KEY = 'bulwark-offline-evicted-owners'; + +export interface EvictedOwner { + username: string; + serverUrl: string; +} + +export function readEvictedOwners(): Record { + return readRecord(OFFLINE_EVICTED_OWNERS_KEY); +} + +export function rememberEvictedOwner(account: string, owner: EvictedOwner): void { + writeRecord(OFFLINE_EVICTED_OWNERS_KEY, { ...readEvictedOwners(), [account]: { username: owner.username, serverUrl: owner.serverUrl } }); +} + +export function dropEvictedOwner(account: string): void { + const owners = readEvictedOwners(); + if (!(account in owners)) return; + delete owners[account]; + writeRecord(OFFLINE_EVICTED_OWNERS_KEY, owners); +} + export function isEvicted(account: string): boolean { return account in readEvicted(); } diff --git a/lib/popout/rescued-drafts.ts b/lib/popout/rescued-drafts.ts index 98a5f7c4d..5e39a2672 100644 --- a/lib/popout/rescued-drafts.ts +++ b/lib/popout/rescued-drafts.ts @@ -46,6 +46,12 @@ export function takeRescuedDraft(): ComposerDraftData | null { return first; } +/** A wipe: the text of a message nobody could save goes too. */ +export function clearRescuedDrafts(): void { + memory = []; + write([]); +} + /** Calls `listener` when a draft comes in. Returns the unsubscribe. */ export function onRescuedDraft(listener: () => void): () => void { listeners.add(listener); diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 68f52791c..19a35c88e 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -38,13 +38,17 @@ const SIGNED_OUT_STORAGE_KEYS = [ 'bulwark-offline-accounts', // Which accounts' data waits for a new sign-in, and until when. 'bulwark-offline-evicted', + 'bulwark-offline-evicted-owners', 'bulwark-store-opened', // What each account chose to keep, and what's known about its store. 'bulwark-offline-scope', 'bulwark-offline-health', ]; -const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-store-opened', 'bulwark-offline-scope', 'bulwark-offline-health']); +const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-offline-evicted-owners', 'bulwark-store-opened', 'bulwark-offline-scope', 'bulwark-offline-health']); + +/** What's the accounts' but isn't the local store's: a wipe of one account that leaves others signed in takes these too. */ +export const ACCOUNT_STORAGE_KEYS = SIGNED_OUT_STORAGE_KEYS.filter((key) => !LOCAL_STORE_KEYS.has(key)); const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; export { SIGNED_OUT_SIGNAL as SIGNED_OUT_KEY }; @@ -131,7 +135,7 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { // it was encrypted with, and the app shell the service worker kept. Also the // database of older versions, in case it wasn't moved yet. if (!options.keepLocalStore) { - void purgeStoreFiles(); + void purgeStoreFiles().catch(() => {}); try { window.indexedDB?.deleteDatabase('bulwark-offline'); } catch { diff --git a/locales/ar/common.json b/locales/ar/common.json index 8bcd738b9..e490f9c93 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "البيانات التي خزّنتها الإضافات هنا", "confirm_files": "المرفقات المفتوحة للتحرير والملفات المنتظرة للإرفاق", "confirm_accounts": "يتم تسجيل الخروج من كل الحسابات", - "confirm_button": "إزالة كل شيء" + "confirm_button": "إزالة كل شيء", + "note_unknown": "أزالت مؤسستك بيانات هذا الحساب من هذا الجهاز. ربما حُذفت أيضًا رسائل لم تُرسل.", + "refused_waiting": "رسائل لم تُرسل تنتظر كلمة مرورك الجديدة: {count}. تبقى حتى {date}.", + "refused_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر كلمة مرورك الجديدة. تبقى حتى {date}.", + "confirm_unsent_unknown": "ربما توجد هنا رسائل لم تُرسل. ستُفقد.", + "confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز" }, "offlineStorage": { "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 4d1827e18..8de0aebcd 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Dades que els connectors han guardat aquí", "confirm_files": "Adjunts oberts per editar i fitxers pendents d'adjuntar", "confirm_accounts": "Es tanca la sessió de tots els comptes", - "confirm_button": "Treu-ho tot" + "confirm_button": "Treu-ho tot", + "note_unknown": "La teva organització ha eliminat les dades d'aquest compte d'aquest dispositiu. És possible que també s'hagin suprimit missatges sense enviar.", + "refused_waiting": "Missatges sense enviar que esperen la teva contrasenya nova: {count}. Es conserven fins al {date}.", + "refused_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant la teva contrasenya nova. Es conserven fins al {date}.", + "confirm_unsent_unknown": "És possible que hi hagi missatges sense enviar esperant aquí. Es perdran.", + "confirm_templates": "Plantilles de correu desades en aquest dispositiu" }, "offlineStorage": { "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest dispositiu. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 24ca5eea3..8fcfc3114 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Data, která sem uložily zásuvné moduly", "confirm_files": "Přílohy otevřené k úpravám a soubory čekající na připojení", "confirm_accounts": "Všechny účty budou odhlášeny", - "confirm_button": "Odebrat vše" + "confirm_button": "Odebrat vše", + "note_unknown": "Vaše organizace odstranila data tohoto účtu z tohoto zařízení. Možná byly smazány i neodeslané zprávy.", + "refused_waiting": "Neodeslané zprávy čekající na nové heslo: {count}. Zůstanou do {date}.", + "refused_waiting_unknown": "Na nové heslo možná čekají neodeslané zprávy. Zůstanou do {date}.", + "confirm_unsent_unknown": "Možná tu čekají neodeslané zprávy. Budou ztraceny.", + "confirm_templates": "Šablony e-mailů uložené v tomto zařízení" }, "offlineStorage": { "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto zařízení. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", diff --git a/locales/da/common.json b/locales/da/common.json index b40ae99fb..0c5508b13 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Data, som plugins har gemt her", "confirm_files": "Vedhæftede filer åbnet til redigering og filer, der venter på at blive vedhæftet", "confirm_accounts": "Alle konti logges ud", - "confirm_button": "Fjern alt" + "confirm_button": "Fjern alt", + "note_unknown": "Din organisation har fjernet denne kontos data fra denne enhed. Usendte beskeder kan også være slettet.", + "refused_waiting": "Usendte beskeder, der venter på din nye adgangskode: {count}. De bliver til {date}.", + "refused_waiting_unknown": "Usendte beskeder venter måske på din nye adgangskode. De bliver til {date}.", + "confirm_unsent_unknown": "Der venter måske usendte beskeder her. De går tabt.", + "confirm_templates": "E-mailskabeloner gemt på denne enhed" }, "offlineStorage": { "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret på denne enhed. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", diff --git a/locales/de/common.json b/locales/de/common.json index 428978ec8..d56c0d7fe 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Daten, die Plugins hier gespeichert haben", "confirm_files": "Zum Bearbeiten geöffnete Anhänge und Dateien, die noch angehängt werden sollen", "confirm_accounts": "Alle Konten werden abgemeldet", - "confirm_button": "Alles entfernen" + "confirm_button": "Alles entfernen", + "note_unknown": "Ihre Organisation hat die Daten dieses Kontos von diesem Gerät entfernt. Dabei wurden möglicherweise auch ungesendete Nachrichten gelöscht.", + "refused_waiting": "Auf Ihr neues Passwort warten ungesendete Nachrichten: {count}. Sie bleiben bis zum {date}.", + "refused_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihr neues Passwort. Sie bleiben bis zum {date}.", + "confirm_unsent_unknown": "Hier warten möglicherweise ungesendete Nachrichten. Sie gehen verloren.", + "confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen" }, "offlineStorage": { "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt auf diesem Gerät. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", diff --git a/locales/en/common.json b/locales/en/common.json index 8d2280fa8..44792b34b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Data that plugins stored here", "confirm_files": "Attachments opened for editing and files waiting to be attached", "confirm_accounts": "All accounts are signed out", - "confirm_button": "Remove everything" + "confirm_button": "Remove everything", + "note_unknown": "Your organisation removed this account's data from this device. Unsent messages may have been deleted too.", + "refused_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for your new password, until {date}.", + "refused_waiting_unknown": "Unsent messages may be waiting for your new password, until {date}.", + "confirm_unsent_unknown": "Unsent messages may be waiting here. They will be lost.", + "confirm_templates": "Email templates saved on this device" }, "offlineStorage": { "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted on this device. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", diff --git a/locales/es/common.json b/locales/es/common.json index 52e2185e2..bf16285c8 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Datos que los plugins guardaron aquí", "confirm_files": "Adjuntos abiertos para editar y archivos a la espera de adjuntarse", "confirm_accounts": "Se cierra la sesión de todas las cuentas", - "confirm_button": "Quitar todo" + "confirm_button": "Quitar todo", + "note_unknown": "Tu organización ha eliminado los datos de esta cuenta de este dispositivo. Puede que también se hayan eliminado mensajes sin enviar.", + "refused_waiting": "Mensajes sin enviar que esperan tu nueva contraseña: {count}. Se conservan hasta el {date}.", + "refused_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu nueva contraseña. Se conservan hasta el {date}.", + "confirm_unsent_unknown": "Puede que haya mensajes sin enviar esperando aquí. Se perderán.", + "confirm_templates": "Plantillas de correo guardadas en este dispositivo" }, "offlineStorage": { "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este dispositivo. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", diff --git a/locales/et/common.json b/locales/et/common.json index dad7bc936..cf9335243 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Pistikprogrammide siia salvestatud andmed", "confirm_files": "Muutmiseks avatud manused ja lisamist ootavad failid", "confirm_accounts": "Kõik kontod logitakse välja", - "confirm_button": "Eemalda kõik" + "confirm_button": "Eemalda kõik", + "note_unknown": "Teie organisatsioon eemaldas selle konto andmed sellest seadmest. Võimalik, et kustutati ka saatmata sõnumid.", + "refused_waiting": "Saatmata sõnumid ootavad teie uut parooli: {count}. Need jäävad alles kuni {date}.", + "refused_waiting_unknown": "Saatmata sõnumid võivad oodata teie uut parooli. Need jäävad alles kuni {date}.", + "confirm_unsent_unknown": "Siin võivad oodata saatmata sõnumid. Need lähevad kaduma.", + "confirm_templates": "Selles seadmes salvestatud e-kirjade mallid" }, "offlineStorage": { "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles seadmes. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", diff --git a/locales/fa/common.json b/locales/fa/common.json index fda14c22a..fdc73532c 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "داده‌هایی که افزونه‌ها اینجا ذخیره کرده‌اند", "confirm_files": "پیوست‌های بازشده برای ویرایش و فایل‌های در انتظار پیوست", "confirm_accounts": "از همهٔ حساب‌ها خارج می‌شوید", - "confirm_button": "حذف همه چیز" + "confirm_button": "حذف همه چیز", + "note_unknown": "سازمان شما داده‌های این حساب را از این دستگاه حذف کرد. شاید پیام‌های ارسال‌نشده هم حذف شده باشند.", + "refused_waiting": "پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما: {count}. تا {date} نگه داشته می‌شوند.", + "refused_waiting_unknown": "شاید پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما باشند. تا {date} نگه داشته می‌شوند.", + "confirm_unsent_unknown": "شاید پیام‌های ارسال‌نشده اینجا منتظر باشند. از بین خواهند رفت.", + "confirm_templates": "الگوهای ایمیل ذخیره‌شده در این دستگاه" }, "offlineStorage": { "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این دستگاه نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", diff --git a/locales/fr/common.json b/locales/fr/common.json index b4687847a..6d00d5ffe 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Données enregistrées ici par les plugins", "confirm_files": "Pièces jointes ouvertes pour modification et fichiers en attente d'être joints", "confirm_accounts": "Tous les comptes sont déconnectés", - "confirm_button": "Tout supprimer" + "confirm_button": "Tout supprimer", + "note_unknown": "Votre organisation a supprimé les données de ce compte sur cet appareil. Des messages non envoyés ont peut-être aussi été supprimés.", + "refused_waiting": "Messages non envoyés qui attendent votre nouveau mot de passe : {count}. Ils restent jusqu'au {date}.", + "refused_waiting_unknown": "Des messages non envoyés attendent peut-être votre nouveau mot de passe. Ils restent jusqu'au {date}.", + "confirm_unsent_unknown": "Des messages non envoyés attendent peut-être ici. Ils seront perdus.", + "confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil" }, "offlineStorage": { "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés sur cet appareil. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", diff --git a/locales/he/common.json b/locales/he/common.json index 7357e5bb6..1570d956c 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5779,7 +5779,12 @@ "confirm_plugins": "נתונים שתוספים שמרו כאן", "confirm_files": "קבצים מצורפים שנפתחו לעריכה וקבצים שממתינים לצירוף", "confirm_accounts": "מתבצעת יציאה מכל החשבונות", - "confirm_button": "הסרת הכול" + "confirm_button": "הסרת הכול", + "note_unknown": "הארגון שלך הסיר את נתוני החשבון הזה מהמכשיר הזה. ייתכן שגם הודעות שלא נשלחו נמחקו.", + "refused_waiting": "הודעות שלא נשלחו ממתינות לסיסמה החדשה שלך: {count}. הן יישמרו עד {date}.", + "refused_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לסיסמה החדשה שלך. הן יישמרו עד {date}.", + "confirm_unsent_unknown": "ייתכן שיש כאן הודעות שלא נשלחו. הן יאבדו.", + "confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה" }, "offlineStorage": { "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים במכשיר הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 545eb48fb..35c5ef39d 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "A bővítmények által itt tárolt adatok", "confirm_files": "Szerkesztésre megnyitott mellékletek és csatolásra váró fájlok", "confirm_accounts": "Minden fiók kijelentkezik", - "confirm_button": "Minden eltávolítása" + "confirm_button": "Minden eltávolítása", + "note_unknown": "A szervezete eltávolította a fiók adatait erről az eszközről. Lehet, hogy az el nem küldött üzenetek is törlődtek.", + "refused_waiting": "Az új jelszóra váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.", + "refused_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak az új jelszóra. Eddig maradnak: {date}.", + "confirm_unsent_unknown": "Lehet, hogy el nem küldött üzenetek várakoznak itt. Elvesznek.", + "confirm_templates": "Ezen az eszközön mentett e-mail-sablonok" }, "offlineStorage": { "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ezen az eszközön. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", diff --git a/locales/it/common.json b/locales/it/common.json index f3d291a47..dea15a985 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Dati salvati qui dai plugin", "confirm_files": "Allegati aperti per la modifica e file in attesa di essere allegati", "confirm_accounts": "Tutti gli account vengono disconnessi", - "confirm_button": "Rimuovi tutto" + "confirm_button": "Rimuovi tutto", + "note_unknown": "La tua organizzazione ha rimosso i dati di questo account da questo dispositivo. Potrebbero essere stati eliminati anche messaggi non inviati.", + "refused_waiting": "Messaggi non inviati in attesa della nuova password: {count}. Restano fino al {date}.", + "refused_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa della nuova password. Restano fino al {date}.", + "confirm_unsent_unknown": "Qui potrebbero esserci messaggi non inviati in attesa. Andranno persi.", + "confirm_templates": "Modelli di email salvati su questo dispositivo" }, "offlineStorage": { "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati su questo dispositivo. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 92015b998..3236ced5c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "プラグインがここに保存したデータ", "confirm_files": "編集のために開いた添付ファイルと、添付待ちのファイル", "confirm_accounts": "すべてのアカウントがサインアウトされます", - "confirm_button": "すべて削除" + "confirm_button": "すべて削除", + "note_unknown": "組織がこのアカウントのデータをこのデバイスから削除しました。未送信のメッセージも削除された可能性があります。", + "refused_waiting": "新しいパスワードを待っている未送信メッセージ: {count} 件。{date} まで保持されます。", + "refused_waiting_unknown": "未送信のメッセージが新しいパスワードを待っている可能性があります。{date} まで保持されます。", + "confirm_unsent_unknown": "ここに未送信のメッセージがある可能性があります。失われます。", + "confirm_templates": "このデバイスに保存されたメールテンプレート" }, "offlineStorage": { "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのデバイスに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index c06ae198c..fb6db31a9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "플러그인이 여기에 저장한 데이터", "confirm_files": "편집하려고 연 첨부 파일과 첨부를 기다리는 파일", "confirm_accounts": "모든 계정이 로그아웃됩니다", - "confirm_button": "모두 제거" + "confirm_button": "모두 제거", + "note_unknown": "조직에서 이 계정의 데이터를 이 기기에서 삭제했습니다. 보내지 않은 메시지도 삭제되었을 수 있습니다.", + "refused_waiting": "새 비밀번호를 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.", + "refused_waiting_unknown": "보내지 않은 메시지가 새 비밀번호를 기다리고 있을 수 있습니다. {date}까지 보관됩니다.", + "confirm_unsent_unknown": "여기에 보내지 않은 메시지가 있을 수 있습니다. 사라지게 됩니다.", + "confirm_templates": "이 기기에 저장된 이메일 템플릿" }, "offlineStorage": { "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 기기에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 7897ac97b..d7f71bc99 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Dati, ko šeit saglabājuši spraudņi", "confirm_files": "Rediģēšanai atvērti pielikumi un faili, kas gaida pievienošanu", "confirm_accounts": "Visi konti tiek izrakstīti", - "confirm_button": "Noņemt visu" + "confirm_button": "Noņemt visu", + "note_unknown": "Jūsu organizācija noņēma šī konta datus no šīs ierīces. Iespējams, ir izdzēsti arī nenosūtītie ziņojumi.", + "refused_waiting": "Nenosūtītie ziņojumi gaida jūsu jauno paroli: {count}. Tie paliek līdz {date}.", + "refused_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida jūsu jauno paroli. Tie paliek līdz {date}.", + "confirm_unsent_unknown": "Šeit var būt nenosūtīti ziņojumi. Tie tiks zaudēti.", + "confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes" }, "offlineStorage": { "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā ierīcē. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", diff --git a/locales/mn/common.json b/locales/mn/common.json index f1b0cab82..24b511a3f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Өргөтгөлүүдийн энд хадгалсан өгөгдөл", "confirm_files": "Засахаар нээсэн хавсралтууд болон хавсаргахаар хүлээж буй файлууд", "confirm_accounts": "Бүх бүртгэлээс гарна", - "confirm_button": "Бүгдийг устгах" + "confirm_button": "Бүгдийг устгах", + "note_unknown": "Таны байгууллага энэ бүртгэлийн өгөгдлийг энэ төхөөрөмжөөс устгасан. Илгээгээгүй зурвасууд мөн устсан байж магадгүй.", + "refused_waiting": "Шинэ нууц үгийг тань хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.", + "refused_waiting_unknown": "Илгээгээгүй зурвасууд шинэ нууц үгийг тань хүлээж байж магадгүй. {date} хүртэл хадгалагдана.", + "confirm_unsent_unknown": "Энд илгээгээгүй зурвас хүлээж байж магадгүй. Тэдгээр нь алдагдана.", + "confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд" }, "offlineStorage": { "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ төхөөрөмж дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 5c0350120..c391ac5c2 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Data som programtillegg har lagret her", "confirm_files": "Vedlegg som er åpnet for redigering, og filer som venter på å bli lagt ved", "confirm_accounts": "Alle kontoer logges av", - "confirm_button": "Fjern alt" + "confirm_button": "Fjern alt", + "note_unknown": "Organisasjonen din har fjernet dataene til denne kontoen fra denne enheten. Usendte meldinger kan også være slettet.", + "refused_waiting": "Usendte meldinger som venter på det nye passordet: {count}. De blir til {date}.", + "refused_waiting_unknown": "Usendte meldinger venter kanskje på det nye passordet. De blir til {date}.", + "confirm_unsent_unknown": "Det venter kanskje usendte meldinger her. De går tapt.", + "confirm_templates": "E-postmaler lagret på denne enheten" }, "offlineStorage": { "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert på denne enheten. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 15f9d1b91..260fa27af 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Gegevens die plug-ins hier hebben opgeslagen", "confirm_files": "Bijlagen die zijn geopend om te bewerken en bestanden die nog worden bijgevoegd", "confirm_accounts": "Alle accounts worden afgemeld", - "confirm_button": "Alles verwijderen" + "confirm_button": "Alles verwijderen", + "note_unknown": "Uw organisatie heeft de gegevens van dit account van dit apparaat verwijderd. Mogelijk zijn ook niet-verzonden berichten verwijderd.", + "refused_waiting": "Niet-verzonden berichten die op uw nieuwe wachtwoord wachten: {count}. Ze blijven tot {date}.", + "refused_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw nieuwe wachtwoord. Ze blijven tot {date}.", + "confirm_unsent_unknown": "Hier wachten mogelijk niet-verzonden berichten. Ze gaan verloren.", + "confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen" }, "offlineStorage": { "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld op dit apparaat. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", diff --git a/locales/pl/common.json b/locales/pl/common.json index d2cfe297b..2ba9c31ea 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Dane zapisane tutaj przez wtyczki", "confirm_files": "Załączniki otwarte do edycji i pliki czekające na dołączenie", "confirm_accounts": "Wszystkie konta zostaną wylogowane", - "confirm_button": "Usuń wszystko" + "confirm_button": "Usuń wszystko", + "note_unknown": "Twoja organizacja usunęła dane tego konta z tego urządzenia. Mogły też zostać usunięte niewysłane wiadomości.", + "refused_waiting": "Niewysłane wiadomości czekające na nowe hasło: {count}. Pozostaną do {date}.", + "refused_waiting_unknown": "Niewysłane wiadomości mogą czekać na nowe hasło. Pozostaną do {date}.", + "confirm_unsent_unknown": "Tutaj mogą czekać niewysłane wiadomości. Zostaną utracone.", + "confirm_templates": "Szablony e-maili zapisane na tym urządzeniu" }, "offlineStorage": { "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane na tym urządzeniu. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4ff4c3722..c37440dcf 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Dados que os plugins guardaram aqui", "confirm_files": "Anexos abertos para edição e ficheiros à espera de serem anexados", "confirm_accounts": "Todas as contas terminam sessão", - "confirm_button": "Remover tudo" + "confirm_button": "Remover tudo", + "note_unknown": "A sua organização removeu os dados desta conta deste dispositivo. Mensagens por enviar também podem ter sido eliminadas.", + "refused_waiting": "Mensagens por enviar à espera da nova palavra-passe: {count}. Ficam até {date}.", + "refused_waiting_unknown": "Podem existir mensagens por enviar à espera da nova palavra-passe. Ficam até {date}.", + "confirm_unsent_unknown": "Podem existir mensagens por enviar aqui. Serão perdidas.", + "confirm_templates": "Modelos de e-mail guardados neste dispositivo" }, "offlineStorage": { "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste dispositivo. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 673492c7e..8a29dfa1e 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Date stocate aici de pluginuri", "confirm_files": "Atașamente deschise pentru editare și fișiere care așteaptă să fie atașate", "confirm_accounts": "Toate conturile sunt deconectate", - "confirm_button": "Elimină tot" + "confirm_button": "Elimină tot", + "note_unknown": "Organizația dvs. a eliminat datele acestui cont de pe acest dispozitiv. Este posibil să fi fost șterse și mesaje netrimise.", + "refused_waiting": "Mesaje netrimise care așteaptă parola nouă: {count}. Rămân până la {date}.", + "refused_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte parola nouă. Rămân până la {date}.", + "confirm_unsent_unknown": "Este posibil să existe aici mesaje netrimise. Se vor pierde.", + "confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv" }, "offlineStorage": { "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate pe acest dispozitiv. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", diff --git a/locales/ru/common.json b/locales/ru/common.json index cf23ff760..0d5aea6f0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Данные, сохранённые здесь плагинами", "confirm_files": "Вложения, открытые для редактирования, и файлы, ожидающие прикрепления", "confirm_accounts": "Выполняется выход из всех учётных записей", - "confirm_button": "Удалить всё" + "confirm_button": "Удалить всё", + "note_unknown": "Ваша организация удалила данные этой учётной записи с этого устройства. Возможно, удалены и неотправленные сообщения.", + "refused_waiting": "Неотправленные сообщения ждут нового пароля: {count}. Они хранятся до {date}.", + "refused_waiting_unknown": "Возможно, неотправленные сообщения ждут нового пароля. Они хранятся до {date}.", + "confirm_unsent_unknown": "Здесь могут быть неотправленные сообщения. Они будут потеряны.", + "confirm_templates": "Шаблоны писем, сохранённые на этом устройстве" }, "offlineStorage": { "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде на этом устройстве. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", diff --git a/locales/sk/common.json b/locales/sk/common.json index e69c8f111..f3494d58c 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Údaje, ktoré sem uložili doplnky", "confirm_files": "Prílohy otvorené na úpravy a súbory čakajúce na priloženie", "confirm_accounts": "Všetky účty sa odhlásia", - "confirm_button": "Odstrániť všetko" + "confirm_button": "Odstrániť všetko", + "note_unknown": "Vaša organizácia odstránila údaje tohto účtu z tohto zariadenia. Možno boli odstránené aj neodoslané správy.", + "refused_waiting": "Neodoslané správy čakajúce na nové heslo: {count}. Zostanú do {date}.", + "refused_waiting_unknown": "Na nové heslo možno čakajú neodoslané správy. Zostanú do {date}.", + "confirm_unsent_unknown": "Možno tu čakajú neodoslané správy. Stratia sa.", + "confirm_templates": "Šablóny e-mailov uložené v tomto zariadení" }, "offlineStorage": { "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto zariadení. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", diff --git a/locales/th/common.json b/locales/th/common.json index 640cf557f..8fb96cbba 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2123,7 +2123,12 @@ "confirm_plugins": "ข้อมูลที่ปลั๊กอินเก็บไว้ที่นี่", "confirm_files": "ไฟล์แนบที่เปิดเพื่อแก้ไขและไฟล์ที่รอแนบ", "confirm_accounts": "ทุกบัญชีจะออกจากระบบ", - "confirm_button": "นำทุกอย่างออก" + "confirm_button": "นำทุกอย่างออก", + "note_unknown": "องค์กรของคุณได้ลบข้อมูลของบัญชีนี้ออกจากอุปกรณ์นี้แล้ว ข้อความที่ยังไม่ได้ส่งอาจถูกลบด้วย", + "refused_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอรหัสผ่านใหม่ของคุณ: {count} เก็บไว้ถึง {date}", + "refused_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอรหัสผ่านใหม่ของคุณ เก็บไว้ถึง {date}", + "confirm_unsent_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรออยู่ที่นี่ ข้อความเหล่านั้นจะสูญหาย", + "confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้" }, "offlineStorage": { "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในอุปกรณ์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", diff --git a/locales/tr/common.json b/locales/tr/common.json index fef33a15b..b59f7dbc8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Eklentilerin burada sakladığı veriler", "confirm_files": "Düzenlemek için açılan ekler ve eklenmeyi bekleyen dosyalar", "confirm_accounts": "Tüm hesapların oturumu kapatılır", - "confirm_button": "Her şeyi kaldır" + "confirm_button": "Her şeyi kaldır", + "note_unknown": "Kuruluşunuz bu hesabın verilerini bu cihazdan kaldırdı. Gönderilmemiş iletiler de silinmiş olabilir.", + "refused_waiting": "Yeni parolanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.", + "refused_waiting_unknown": "Gönderilmemiş iletiler yeni parolanızı bekliyor olabilir. {date} tarihine kadar kalır.", + "confirm_unsent_unknown": "Burada gönderilmemiş iletiler bekliyor olabilir. Kaybolacaklar.", + "confirm_templates": "Bu cihazda kayıtlı e-posta şablonları" }, "offlineStorage": { "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu cihazda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 76c320da0..0d3fa84bd 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "Дані, збережені тут плагінами", "confirm_files": "Вкладення, відкриті для редагування, і файли, що очікують прикріплення", "confirm_accounts": "Вихід з усіх облікових записів", - "confirm_button": "Видалити все" + "confirm_button": "Видалити все", + "note_unknown": "Ваша організація видалила дані цього облікового запису з цього пристрою. Можливо, видалено й ненадіслані повідомлення.", + "refused_waiting": "Ненадіслані повідомлення чекають на новий пароль: {count}. Вони зберігаються до {date}.", + "refused_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на новий пароль. Вони зберігаються до {date}.", + "confirm_unsent_unknown": "Тут можуть бути ненадіслані повідомлення. Їх буде втрачено.", + "confirm_templates": "Шаблони листів, збережені на цьому пристрої" }, "offlineStorage": { "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді на цьому пристрої. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 9ef2a0f8f..a2353052f 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5790,7 +5790,12 @@ "confirm_plugins": "Dữ liệu mà các tiện ích đã lưu ở đây", "confirm_files": "Tệp đính kèm mở để chỉnh sửa và tệp đang chờ đính kèm", "confirm_accounts": "Tất cả tài khoản sẽ đăng xuất", - "confirm_button": "Xóa mọi thứ" + "confirm_button": "Xóa mọi thứ", + "note_unknown": "Tổ chức của bạn đã xóa dữ liệu của tài khoản này khỏi thiết bị này. Các thư chưa gửi cũng có thể đã bị xóa.", + "refused_waiting": "Thư chưa gửi đang chờ mật khẩu mới của bạn: {count}. Được giữ đến {date}.", + "refused_waiting_unknown": "Có thể có thư chưa gửi đang chờ mật khẩu mới của bạn. Được giữ đến {date}.", + "confirm_unsent_unknown": "Có thể có thư chưa gửi đang chờ ở đây. Chúng sẽ bị mất.", + "confirm_templates": "Mẫu email đã lưu trên thiết bị này" }, "offlineStorage": { "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trên thiết bị này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b1eefd2d2..9557c8336 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "外掛程式儲存在這裡的資料", "confirm_files": "為編輯而開啟的附件和等待附加的檔案", "confirm_accounts": "所有帳號都將登出", - "confirm_button": "移除所有內容" + "confirm_button": "移除所有內容", + "note_unknown": "您的組織已從此裝置移除此帳戶的資料。未傳送的郵件可能也已被刪除。", + "refused_waiting": "等待新密碼的未傳送郵件:{count} 封。保留至 {date}。", + "refused_waiting_unknown": "可能有未傳送的郵件正在等待新密碼。保留至 {date}。", + "confirm_unsent_unknown": "這裡可能有未傳送的郵件。它們將會遺失。", + "confirm_templates": "儲存在此裝置上的郵件範本" }, "offlineStorage": { "keep_description": "在此裝置上加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 571fdd26c..0e054f6a1 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5778,7 +5778,12 @@ "confirm_plugins": "插件保存在这里的数据", "confirm_files": "为编辑而打开的附件和等待添加的文件", "confirm_accounts": "所有帐户都将退出登录", - "confirm_button": "移除所有内容" + "confirm_button": "移除所有内容", + "note_unknown": "您的组织已从此设备中删除此账户的数据。未发送的邮件可能也已被删除。", + "refused_waiting": "等待新密码的未发送邮件:{count} 封。保留至 {date}。", + "refused_waiting_unknown": "可能有未发送的邮件在等待新密码。保留至 {date}。", + "confirm_unsent_unknown": "这里可能有未发送的邮件。它们将会丢失。", + "confirm_templates": "保存在此设备上的邮件模板" }, "offlineStorage": { "keep_description": "在此设备上加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", diff --git a/stores/__tests__/auth-store-device-wipe.test.ts b/stores/__tests__/auth-store-device-wipe.test.ts index 3c9746234..ec7706b62 100644 --- a/stores/__tests__/auth-store-device-wipe.test.ts +++ b/stores/__tests__/auth-store-device-wipe.test.ts @@ -2,8 +2,8 @@ import 'fake-indexeddb/auto'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Where the auth store meets wipes: the check at a start comes before any local -// data, a login the server ended fires the hook on every path, and a password -// it keeps refusing signs out and starts a wait instead of wiping. +// data, a refused login fires the hook on every path and only ever hides the data, +// and the organisation's session limit isn't a refusal at all. const clients = vi.hoisted(() => ({ connect: (async () => {}) as (client: unknown) => Promise, @@ -50,14 +50,16 @@ vi.mock('@/lib/offline/offline-mode', () => ({ })); const heard = vi.hoisted(() => [] as string[]); +const certain = vi.hoisted(() => [] as boolean[]); vi.mock('@/lib/offline/credentials-rejected', () => ({ - noteCredentialsRejected: (account: string) => { heard.push(account); }, + noteCredentialsRejected: (account: string, sure = true) => { heard.push(account); certain.push(sure); }, onCredentialsRejected: () => () => {}, })); import { readRefusedLogins, CREDENTIALS_REFUSED_CONFIRM_MS, markWipePending, clearWipePending } from '@/lib/devices/wipe-state'; // Fresh for each test: a sign-out leaves slots closing in the module for a while. +let isEvicted: typeof import('@/lib/offline/state').isEvicted; let useOfflineStore: typeof import('@/lib/offline/state').useOfflineStore; let useAccountStore: typeof import('@/stores/account-store').useAccountStore; let useAuthStore: typeof import('@/stores/auth-store').useAuthStore; @@ -78,7 +80,7 @@ const json = (status: number, body: unknown) => new Response(JSON.stringify(body beforeEach(async () => { vi.resetModules(); - ({ useOfflineStore } = await import('@/lib/offline/state')); + ({ useOfflineStore, isEvicted } = await import('@/lib/offline/state')); ({ useAccountStore } = await import('@/stores/account-store')); ({ useAuthStore } = await import('@/stores/auth-store')); const nav = await import('@/lib/browser-navigation'); @@ -86,6 +88,7 @@ beforeEach(async () => { sessionStorage.clear(); order.length = 0; heard.length = 0; + certain.length = 0; clients.made.length = 0; clients.connect = async () => {}; gate.run = async () => {}; @@ -161,17 +164,31 @@ describe('a login the server ended, at a start', () => { useAuthStore.setState({ activeAccountId: ALICE }); }); - it('invalid_grant fires the hook', async () => { + it('invalid_grant fires the hook, for certain, and only hides the data', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); await useAuthStore.getState().checkAuth(); - expect(heard).toEqual([ALICE]); + // With mail kept here the real connect goes on behind the local copy. + await vi.waitFor(() => expect(heard).toEqual([ALICE])); + expect(certain).toEqual([true]); + expect(isEvicted(ALICE)).toBe(true); + expect(useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); }); - it('the session limit fires the hook, for a password login too', async () => { - useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')] } as never); - answer = (url, method) => (url.includes('/api/auth/session') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); + it.each([ + ['a password login', 'basic', '/api/auth/session'], + ['an OAuth login', 'oauth', '/api/auth/token'], + ])('the session limit is no refusal, for %s: no hook, no wait, and the account is only dropped', async (_name, mode, route) => { + useOfflineStore.setState({ enabled: [ALICE] }); + useAccountStore.setState({ accounts: [account(ALICE, 0, mode as 'basic' | 'oauth')] } as never); + answer = (url, method) => (url.includes(route) && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); await useAuthStore.getState().checkAuth(); - expect(heard).toEqual([ALICE]); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expect(heard).toEqual([]); + expect(readRefusedLogins()).toEqual({}); + expect(isEvicted(ALICE)).toBe(true); + expect(useOfflineStore.getState().enabled).toEqual([ALICE]); }); it.each([ @@ -200,11 +217,23 @@ describe('a login the server ended, at a switch', () => { useAuthStore.setState({ activeAccountId: ALICE, isAuthenticated: true }); }); - it('invalid_grant fires the hook and takes the account out for good', async () => { + it('invalid_grant fires the hook and hides the account, keeping its data', async () => { + useOfflineStore.setState({ enabled: [ALICE, BOB] }); answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); await useAuthStore.getState().switchAccount(BOB); expect(heard).toEqual([BOB]); expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined(); + expect(isEvicted(BOB)).toBe(true); + expect(useOfflineStore.getState().enabled).toContain(BOB); + }); + + it('the session limit doesn\'t fire it and only drops the account', async () => { + useOfflineStore.setState({ enabled: [ALICE, BOB] }); + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); + await useAuthStore.getState().switchAccount(BOB); + expect(heard).toEqual([]); + expect(isEvicted(BOB)).toBe(true); + expect(useOfflineStore.getState().enabled).toContain(BOB); }); it('another refusal doesn\'t', async () => { @@ -221,16 +250,65 @@ describe('a password the server keeps refusing', () => { useOfflineStore.setState({ enabled: [ALICE] }); }); - it('at a start: the wait begins, and no wipe', async () => { + it('at a start: signed out and hidden, not for certain, since one answer hasn\'t lasted', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + clients.connect = async (client) => { + (client as { refused?: (ms: number) => void }).refused?.(0); + throw new Error('Invalid username or password'); + }; + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(heard).toEqual([ALICE])); + expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); + expect(certain).toEqual([false]); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(isEvicted(ALICE)).toBe(true); + }); + + describe('that recovered', () => { + /** Gone from this page and back, with a session cookie that ran out: nothing says anything about the password. */ + async function backWithAFailingServer(): Promise { + useAuthStore.getState().removeAccount(BOB, 'wiped'); + useAccountStore.setState((state) => ({ accounts: [...state.accounts, account(BOB, 1, 'basic')] }) as never); + answer = (url, method) => (url.includes('/api/auth/session') && method === 'PUT' ? json(401, { error: 'No session' }) : null); + await useAuthStore.getState().switchAccount(BOB); + // Got as far as the failure that would read the mark. + expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined(); + } + + beforeEach(() => { + useOfflineStore.setState({ enabled: [] }); + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic'), account(BOB, 1, 'basic')] } as never); + }); + + it('during the connect: a later failure isn\'t read as a refusal', async () => { + clients.connect = async (client) => { + if ((client as { username?: string }).username === 'bob') (client as { refused?: (ms: number) => void }).refused?.(0); + }; + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(clients.made.length).toBeGreaterThanOrEqual(2)); + await vi.waitFor(() => expect(useAuthStore.getState().getClientForAccount(BOB)).toBeTruthy()); + await backWithAFailingServer(); + expect(heard).toEqual([]); + }); + + it('while running: a short refusal leaves no mark either', async () => { + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(useAuthStore.getState().getClientForAccount(BOB)).toBeTruthy()); + (useAuthStore.getState().getClientForAccount(BOB) as unknown as { refused: (ms: number) => void }).refused(0); + await backWithAFailingServer(); + expect(heard).toEqual([]); + }); + }); + + it('a refusal at a start in a page with no offline kept records no wait', async () => { useOfflineStore.setState({ enabled: [] }); clients.connect = async (client) => { (client as { refused?: (ms: number) => void }).refused?.(0); throw new Error('Invalid username or password'); }; await useAuthStore.getState().checkAuth(); - expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); - expect(heard).toEqual([]); - expect(useAccountStore.getState().accounts).toEqual([]); + expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([ALICE]); }); it('at a start, with a server that only failed: no wait', async () => { @@ -259,12 +337,15 @@ describe('a password the server keeps refusing', () => { expect(readRefusedLogins()).toEqual({}); }); - it('lasting a minute signs out and starts the wait, and fires no hook', async () => { + it('lasting a minute signs out at once and hides the data, for certain', async () => { const client = await running(); + useOfflineStore.setState({ enabled: [ALICE] }); client.refused(CREDENTIALS_REFUSED_CONFIRM_MS); await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); - expect(heard).toEqual([]); + expect(heard).toEqual([ALICE]); + expect(certain).toEqual([true]); + expect(isEvicted(ALICE)).toBe(true); expect(sessionStorage.getItem('session_expired')).toBe('true'); }); }); diff --git a/stores/__tests__/auth-store-keep-local.test.ts b/stores/__tests__/auth-store-keep-local.test.ts index 99ba77f90..a22650c61 100644 --- a/stores/__tests__/auth-store-keep-local.test.ts +++ b/stores/__tests__/auth-store-keep-local.test.ts @@ -99,20 +99,22 @@ describe('an automatic sign-out of the active account', () => { expectKept(); }); - it('wipes on a real invalid_grant, and names the lost mail', async () => { + it('keeps the data on a real invalid_grant: a password changed elsewhere gets it back with the new one', async () => { tokenAnswers(() => answer(401, { error: 'invalid_grant' })); offline.startOfflineMode(); await refresh(); - await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); - expect(localStorage.getItem(state.OFFLINE_ACCOUNTS_KEY)).toBeNull(); - expect(lost.consumeLost()).toEqual(['Unsent']); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expectKept(); + expect(lost.consumeLost()).toEqual([]); }); - it('wipes at the organisation\'s session limit', async () => { + it('keeps the data at the organisation\'s session limit: that says nothing about the password', async () => { const { SESSION_TOO_OLD } = await import('@/lib/session-policy/constants'); tokenAnswers(() => answer(401, { error: SESSION_TOO_OLD, maxHours: 8 })); + offline.startOfflineMode(); await refresh(); - await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expectKept(); }); }); @@ -134,11 +136,12 @@ describe('a background account', () => { expect(store.removed).toEqual([]); }); - it('wipes on a real invalid_grant', async () => { + it('keeps its data on a real invalid_grant too', async () => { tokenAnswers(() => answer(401, { error: 'invalid_grant' })); await token(); - await vi.waitFor(() => expect(store.removed).toEqual([BOB])); - expect(state.isEvicted(BOB)).toBe(false); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined()); + expect(state.isEvicted(BOB)).toBe(true); + expect(store.removed).toEqual([]); }); }); diff --git a/stores/account-store.ts b/stores/account-store.ts index 836dada92..714f365d6 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -5,9 +5,9 @@ import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { forgetDesktopServer } from '@/lib/desktop/bridge'; import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils'; import { authHooks } from '@/lib/plugin-hooks'; -import { clearEvicted, markEvicted } from '@/lib/offline/state'; +import { clearEvicted, dropEvictedOwner, isEvicted, markEvicted, rememberEvictedOwner } from '@/lib/offline/state'; -export type AccountRemovalReason = 'explicit' | 'evicted'; +export type AccountRemovalReason = 'explicit' | 'evicted' | 'wiped'; const removalListeners = new Set<(accountId: string, reason: AccountRemovalReason) => void>(); @@ -124,6 +124,7 @@ export const useAccountStore = create()( const id = generateAccountId(entry.username, entry.serverUrl); clearEvicted(id); + dropEvictedOwner(id); if (state.accounts.some((a) => a.id === id)) { // Already exists - update mutable fields and return existing id set((s) => ({ @@ -177,7 +178,10 @@ export const useAccountStore = create()( removeAccount: (accountId, reason = 'explicit') => { if (!get().accounts.some((a) => a.id === accountId)) return; const leaving = get().accounts.find((a) => a.id === accountId); - if (reason === 'evicted') markEvicted(accountId); + if (reason === 'evicted') { + markEvicted(accountId); + if (IS_DESKTOP_BUILD && leaving && isEvicted(accountId)) rememberEvictedOwner(accountId, leaving); + } for (const listener of [...removalListeners]) listener(accountId, reason); set((s) => { const remaining = s.accounts.filter((a) => a.id !== accountId); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index f3644975b..487439032 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -39,7 +39,7 @@ import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/ch import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role'; import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state'; -import { readEvicted, useOfflineStore } from '@/lib/offline/state'; +import { markEvicted, readEvicted, useOfflineStore } from '@/lib/offline/state'; import { toAsciiEmail } from '@/lib/idn'; import { flushComposers } from '@/lib/compose-flush-registry'; import { sortIdentities } from '@/lib/identity-order'; @@ -796,23 +796,44 @@ export function pickEndSessionSlot(accounts: AccountEntry[], activeAccountId: st const refusedAtConnect = new Set(); const endingRefused = new Set(); +/** A client that's connected: what the server said on the way there is over. */ +function holdClient(accountId: string, client: JMAPClient): void { + clients.set(accountId, client); + refusedAtConnect.delete(accountId); +} + /** - * The server kept refusing this login, after a fresh try each time. The account - * signs out and its data waits a day for a sign-in with the new password - * (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). It has no parsed - * refusal like `invalid_grant` to go by, so nothing goes at once. + * The server refused this login, and the data hides from here on. It waits for the + * new password, or goes after 30 days, or now if the organisation asks for that + * (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). The usual cause is a + * password changed on another device: nothing goes by itself, unsent mail included. + * `certain`: `invalid_grant`, or a 401 that lasted. Not one bad answer at a connect. + */ +function noteRefusedLogin(accountId: string, certain: boolean): void { + if (useOfflineStore.getState().enabled.includes(accountId)) { + markLoginRefused(accountId, certain); + markEvicted(accountId); + } + noteCredentialsRejected(accountId, certain); +} + +/** + * The server kept refusing this login, after a fresh try each time: it signs out + * at once. It has no parsed refusal like `invalid_grant` to go by, and this is the + * only word a password login ever gets. */ async function endRefusedLogin(accountId: string, get: () => AuthState): Promise { const account = useAccountStore.getState().getAccountById(accountId); if (!account || endingRefused.has(accountId)) return; endingRefused.add(accountId); try { - markLoginRefused(accountId); if (get().activeAccountId !== accountId) { + noteRefusedLogin(accountId, true); get().removeAccount(accountId, 'evicted'); return; } await saveDraftsBeforeExpiry(account.cookieSlot ?? 0); + noteRefusedLogin(accountId, true); notifyParent('sso:session-expired'); markSessionExpired(); await get().logout({ endProviderSession: false, keepLocalData: true }); @@ -830,9 +851,11 @@ function bindClientStatusHandlers( client.onCredentialsRefused?.((refusedForMs) => { // A pop-out is on another window's session. Lite keeps no data to wait. if (!accountId || IS_LITE || isPopoutWindow()) return; - refusedAtConnect.add(accountId); // Still connecting: the restore that started this decides, when it fails. - if (clients.get(accountId) !== client) return; + if (clients.get(accountId) !== client) { + refusedAtConnect.add(accountId); + return; + } if (refusedForMs >= CREDENTIALS_REFUSED_CONFIRM_MS) void endRefusedLogin(accountId, get); }); @@ -1290,14 +1313,15 @@ function recordPermanentRefreshFailure(status: number, accountId?: string): bool } /** - * Whether a refused renewal ends the login for real, which wipes the account's - * local data: the server said invalid_grant, or the organisation's session limit - * was reached. Anything else, including a body we can't read, keeps the data. + * Whether the server refused the login itself: invalid_grant. That signs out and + * hides the data (noteRefusedLogin). Anything else keeps it the same way with no + * word to anyone, including a body we can't read and the organisation's session + * limit, which says the session is old and nothing about the password. */ -async function loginReallyOver(res: Response): Promise { +async function loginRefused(res: Response): Promise { try { const { error } = await res.clone().json() as { error?: unknown }; - return error === 'invalid_grant' || error === SESSION_TOO_OLD; + return error === 'invalid_grant'; } catch { return false; } @@ -1742,7 +1766,7 @@ export const useAuthStore = create()( initializeFeatureStores(client); // Store client in multi-account map - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.addAccount({ @@ -1883,7 +1907,7 @@ export const useAuthStore = create()( const { identities, primaryIdentity } = loadIdentities(rawIdentities, username); initializeFeatureStores(client); - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.addAccount({ @@ -1992,7 +2016,7 @@ export const useAuthStore = create()( }); // The registry is typed for the real client; the unified views only // use the IJMAPClient surface, which the demo client implements. - clients.set(id, demoClient as unknown as JMAPClient); + holdClient(id, demoClient as unknown as JMAPClient); return id; }; const demoAccountId = register(client, 'Meridian Labs', true); @@ -2099,7 +2123,7 @@ export const useAuthStore = create()( clearAllStores(); } - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.addAccount({ @@ -2248,7 +2272,7 @@ export const useAuthStore = create()( clearAllStores(); } - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.addAccount({ @@ -2372,17 +2396,18 @@ export const useAuthStore = create()( // maintenance windows and offline spells. if (res.status === 401) { resetRefreshBackoff(accountId ?? undefined); - const over = await loginReallyOver(res); - if (over && accountId) noteCredentialsRejected(accountId); + const refused = await loginRefused(res); // Only this login's refresh token was refused. if (accountId && !isActive) { - get().removeAccount(accountId, over ? 'explicit' : 'evicted'); + if (refused) noteRefusedLogin(accountId, true); + get().removeAccount(accountId, 'evicted'); return null; } await saveDraftsBeforeExpiry(slot); + if (refused && accountId) noteRefusedLogin(accountId, true); notifyParent('sso:session-expired'); markSessionExpired(); - get().logout({ endProviderSession: false, keepLocalData: !over }); + get().logout({ endProviderSession: false, keepLocalData: true }); return null; } // A Bulwark-side failure (500/502) that keeps repeating will not @@ -2712,7 +2737,7 @@ export const useAuthStore = create()( let targetClient = clients.get(accountId); const wasConnected = !!targetClient; let targetRestoreRateLimited = false; - let targetLoginOver = false; + let targetRefused = false; if (!targetClient) { // Null out the client immediately so the page doesn't fire data-loading @@ -2729,13 +2754,13 @@ export const useAuthStore = create()( try { if (targetAccount.authMode === 'oauth') { const res = await fetchSlotAccessToken(targetAccount.cookieSlot, { remember: targetAccount.rememberMe }); - if (!res.ok && res.status === 401) targetLoginOver = await loginReallyOver(res); + if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); if (res.ok) { const { access_token, expires_in } = await res.json(); targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => get().refreshAccessToken({ accountId })); bindClientStatusHandlers(targetClient, set, get, accountId); await targetClient.connect(); - clients.set(accountId, targetClient); + holdClient(accountId, targetClient); scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId }), accountId); await syncStalwartAuthContext( targetAccount.serverUrl, @@ -2746,13 +2771,13 @@ export const useAuthStore = create()( } } else if (targetAccount.rememberMe) { const res = await fetchSlotSession(targetAccount.cookieSlot); - if (!res.ok && res.status === 401) targetLoginOver = await loginReallyOver(res); + if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); if (res.ok) { const session: RememberedSession = await res.json(); targetClient = clientForSession(session); bindClientStatusHandlers(targetClient, set, get, accountId); await targetClient.connect(); - clients.set(accountId, targetClient); + holdClient(accountId, targetClient); await syncStalwartAuthContext(session.serverUrl, session.username, targetClient.getAuthHeader(), targetAccount.cookieSlot); } } @@ -2798,11 +2823,10 @@ export const useAuthStore = create()( } // Cannot restore - remove the stale account and redirect to login - if (targetLoginOver) noteCredentialsRejected(accountId); - else if (refusedAtConnect.has(accountId)) markLoginRefused(accountId); + if (targetRefused || refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, targetRefused); refusedAtConnect.delete(accountId); evictAccount(accountId); - accountStore.removeAccount(accountId, targetLoginOver ? 'explicit' : 'evicted'); + accountStore.removeAccount(accountId, 'evicted'); void clearSlotCredentials(targetAccount.cookieSlot, targetAccount.authMode === 'oauth'); // Restore the previous account if still available @@ -3040,7 +3064,7 @@ export const useAuthStore = create()( bindClientStatusHandlers(client, set, get, account.id); const contextSync = syncStalwartAuthContext(account.serverUrl, account.username, client.getAuthHeader(), account.cookieSlot); await client.connect(); - clients.set(account.id, client); + holdClient(account.id, client); scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId: account.id }), account.id); await contextSync; accountStore.updateAccount(account.id, { isConnected: true, hasError: false }); @@ -3051,7 +3075,7 @@ export const useAuthStore = create()( // Repeated 500/502 (see recordPermanentRefreshFailure) falls // through here and evicts the account like a rejection. (#972) if (res.status >= 500) notifySignInAgain(); - throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { loginOver: await loginReallyOver(res) }); + throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { refused: await loginRefused(res) }); } } else { const res = await fetchSlotSession(account.cookieSlot); @@ -3061,7 +3085,7 @@ export const useAuthStore = create()( bindClientStatusHandlers(client, set, get, account.id); const contextSync = syncStalwartAuthContext(session.serverUrl, session.username, client.getAuthHeader(), account.cookieSlot); await client.connect(); - clients.set(account.id, client); + holdClient(account.id, client); await contextSync; accountStore.updateAccount(account.id, { isConnected: true, @@ -3072,7 +3096,7 @@ export const useAuthStore = create()( } else if (res.status >= 500 || res.status === 429) { throw new TransientAuthError('Session restore failed', res.status); } else { - throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { loginOver: await loginReallyOver(res) }); + throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { refused: await loginRefused(res) }); } } } catch (err) { @@ -3095,7 +3119,7 @@ export const useAuthStore = create()( // only; a reload once the server is back signs in as usual. const offlineClient = await startOfflineClient(account.id, account.serverUrl, account.username); if (offlineClient) { - clients.set(account.id, offlineClient); + holdClient(account.id, offlineClient); accountStore.updateAccount(account.id, { isConnected: true, hasError: false }); return; } @@ -3109,13 +3133,12 @@ export const useAuthStore = create()( // Remove unrestorable accounts so the user is prompted to log in // again rather than seeing a stale error entry forever. Its // refresh token goes with it: nothing would use or revoke it. Its - // local data waits, unless the server really ended the login. - const loginOver = (err as { loginOver?: boolean }).loginOver === true; - if (loginOver) noteCredentialsRejected(account.id); - else if (refusedAtConnect.has(account.id)) markLoginRefused(account.id); + // local data waits. + const refused = (err as { refused?: boolean }).refused === true; + if (refused || refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, refused); refusedAtConnect.delete(account.id); evictAccount(account.id); - accountStore.removeAccount(account.id, loginOver ? 'explicit' : 'evicted'); + accountStore.removeAccount(account.id, 'evicted'); void clearSlotCredentials(account.cookieSlot, account.authMode === 'oauth'); } }; @@ -3132,7 +3155,7 @@ export const useAuthStore = create()( ? await startLocalCopyClient(targetEntry.id, targetEntry.serverUrl, targetEntry.username) : null; if (localCopy && targetEntry) { - clients.set(targetEntry.id, localCopy); + holdClient(targetEntry.id, localCopy); accountStore.updateAccount(targetEntry.id, { isConnected: true, hasError: false }); } else if (targetEntry) { await restoreAccount(targetEntry); @@ -3150,7 +3173,7 @@ export const useAuthStore = create()( const real = clients.get(entry.id); if (!real) { if (useAccountStore.getState().getAccountById(entry.id)) { - clients.set(entry.id, local); + holdClient(entry.id, local); // A 5xx or a rate limit, with the server up otherwise. The app stays // on the local copy, and says so, and leaves it when the server answers. void import('@/lib/offline/offline-mode').then((offline) => offline.markStartedOffline()).catch(() => {}); @@ -3332,7 +3355,7 @@ export const useAuthStore = create()( await client.connect(); const accountId = generateAccountId(state.username || '', state.serverUrl); - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); // Migrate to account registry @@ -3400,7 +3423,7 @@ export const useAuthStore = create()( await client.connect(); const accountId = generateAccountId(username, serverUrl); - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); // Migrate to account registry @@ -3511,7 +3534,7 @@ export const useAuthStore = create()( rememberMe || IS_LITE ? persistSession(cookieSlot, { serverUrl, username, password }) : null, syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot), ]); - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.updateAccount(accountId, { awaitingSignIn: false, isConnected: true, hasError: false, errorMessage: undefined, @@ -3586,14 +3609,14 @@ export const useAuthStore = create()( if (!client) return null; bindClientStatusHandlers(client, set, get, accountId); await client.connect(); - clients.set(accountId, client); + holdClient(accountId, client); accountStore.updateAccount(accountId, { isConnected: true, hasError: false }); return client; } catch (error) { debug.error(`A pop-out could not use account ${accountId}:`, error); if (!isTransientAuthError(error)) return null; const offlineClient = await startOfflineClient(accountId, account.serverUrl, account.username); - if (offlineClient) clients.set(accountId, offlineClient); + if (offlineClient) holdClient(accountId, offlineClient); return offlineClient; } }; From 88cd1e4cc4969cd6e238980ee04ea4d942606b08 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:18:58 +0100 Subject: [PATCH 25/45] fix: ask about a forgotten PIN on the lock screen itself, where it can be seen --- .../app-lock/__tests__/lock-screen.test.tsx | 25 ++++++ components/app-lock/lock-screen.tsx | 41 ++++++---- integration/tests/24-do-app-lock.spec.ts | 77 +++++++++++++++---- lib/app-lock/vault.ts | 3 - 4 files changed, 112 insertions(+), 34 deletions(-) diff --git a/components/app-lock/__tests__/lock-screen.test.tsx b/components/app-lock/__tests__/lock-screen.test.tsx index 175e0994b..b2be7f72e 100644 --- a/components/app-lock/__tests__/lock-screen.test.tsx +++ b/components/app-lock/__tests__/lock-screen.test.tsx @@ -9,9 +9,11 @@ import { writeAppLockFile } from '@/lib/app-lock/state'; vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) })); const unlockWithPin = vi.hoisted(() => vi.fn()); +const forgetEverything = vi.hoisted(() => vi.fn()); vi.mock('@/lib/app-lock/controller', async (original) => ({ ...(await original()), unlockWithPin, + forgetEverything, })); function setPolicy(required: boolean, minutes: number | null, locked: boolean): void { @@ -32,6 +34,7 @@ beforeEach(async () => { await deleteVault(); localStorage.clear(); unlockWithPin.mockReset(); + forgetEverything.mockReset(); }); afterEach(() => { @@ -83,6 +86,28 @@ describe('the lock screen', () => { expect(screen.queryByTestId('app-lock-os')).toBeNull(); }); + it('asks about a forgotten PIN inside the lock screen: everything on beside it is hidden', async () => { + (await createVault('correct-pin')).fill(0); + render(); + fireEvent.click(await screen.findByTestId('app-lock-forgot')); + const confirm = screen.getByRole('button', { name: 'forgot_confirm' }); + expect(confirm.closest('[data-app-lock-overlay]')).not.toBeNull(); + expect(screen.getByRole('dialog')).toHaveTextContent('forgot_body'); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument(); + expect(forgetEverything).not.toHaveBeenCalled(); + }); + + it('forgets only after the yes, and comes back when main says no', async () => { + (await createVault('correct-pin')).fill(0); + forgetEverything.mockResolvedValue(false); + render(); + fireEvent.click(await screen.findByTestId('app-lock-forgot')); + fireEvent.click(screen.getByRole('button', { name: 'forgot_confirm' })); + await waitFor(() => expect(forgetEverything).toHaveBeenCalledTimes(1)); + expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument(); + }); + it('says it\'s saving rather than ask for anything while it locks', () => { render(); expect(screen.queryByTestId('app-lock-pin')).toBeNull(); diff --git a/components/app-lock/lock-screen.tsx b/components/app-lock/lock-screen.tsx index 20f37bc3d..9d69b3529 100644 --- a/components/app-lock/lock-screen.tsx +++ b/components/app-lock/lock-screen.tsx @@ -4,10 +4,8 @@ import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from import { useTranslations } from "next-intl"; import { Lock } from "@/components/icons"; import { Button } from "@/components/ui/button"; -import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Input } from "@/components/ui/input"; import { useConfig } from "@/hooks/use-config"; -import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { NewPinFields, newPinReady } from "./pin-fields"; import { enableAppLock, forgetEverything, unlockWithOs, unlockWithPasskey, unlockWithPin, type AppLockPhase } from "@/lib/app-lock/controller"; import { passkeysPossible } from "@/lib/app-lock/passkey"; @@ -118,7 +116,7 @@ function SetupForm() { function UnlockForm() { const t = useTranslations("appLock"); const appName = productName(useConfig().appName); - const { dialogProps, confirm } = useConfirmDialog(); + const [forgetting, setForgetting] = useState(false); const [pin, setPin] = useState(""); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); @@ -180,15 +178,31 @@ function UnlockForm() { } }; - const forgot = async () => { - const ok = await confirm({ - title: t("forgot_title"), - message: t("forgot_body"), - confirmText: t("forgot_confirm"), - variant: "destructive", - }); - if (ok) await forgetEverything(); - }; + // Here and not in a dialog: a dialog is a portal on , which the lock keeps hidden. + if (forgetting) { + return ( + +
+ + +
+ + ); + } if (mainThere) { return ( @@ -241,11 +255,10 @@ function UnlockForm() { {t("use_passkey")} )} - - ); } diff --git a/integration/tests/24-do-app-lock.spec.ts b/integration/tests/24-do-app-lock.spec.ts index 9b76d81ac..bf4b3f85e 100644 --- a/integration/tests/24-do-app-lock.spec.ts +++ b/integration/tests/24-do-app-lock.spec.ts @@ -36,7 +36,8 @@ async function enableOffline(page: Page): Promise { await expect(toggle).toBeVisible({ timeout: 30_000 }); 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 }); + // The store is open once its key record is there. A full first sync isn't what's tested. + await expect.poll(async () => (await keyRecords(page).catch(() => [])).length, { timeout: 60_000 }).toBeGreaterThan(0); } async function enableLock(page: Page, pin = PIN): Promise { @@ -52,7 +53,7 @@ async function enableLock(page: Page, pin = PIN): Promise { } async function lockNow(page: Page): Promise { - await page.goto('/settings/general'); + if (!page.url().includes('/settings/general')) await gotoUnlocked(page, '/settings/general'); await page.getByTestId('app-lock-now').click(); await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); } @@ -63,8 +64,18 @@ async function unlock(page: Page, pin = PIN): Promise { await expect(lockForm(page)).toBeHidden({ timeout: 60_000 }); } +/** + * A full page load in the tab that holds the key loses the key, so it asks again. That's + * the design: the key isn't kept anywhere that a reload could find it. + */ +async function gotoUnlocked(page: Page, path: string): Promise { + await page.goto(path); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await unlock(page); +} + async function toMail(page: Page): Promise { - await page.goto('/mail'); + await gotoUnlocked(page, '/mail'); await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); } @@ -79,10 +90,20 @@ async function browserStorage(page: Page): Promise { /** The account key records as they are on disk. */ async function keyRecords(page: Page): Promise> { - return page.evaluate(() => new Promise((resolve, reject) => { + return page.evaluate(async () => new Promise(async (resolve, reject) => { + // Opening one that doesn't exist would create it, empty, and break the app's own open. + if (!(await indexedDB.databases()).some((db) => db.name === 'bulwark-store')) { + resolve([]); + return; + } const open = indexedDB.open('bulwark-store'); open.onerror = () => reject(open.error); open.onsuccess = () => { + if (!open.result.objectStoreNames.contains('accounts')) { + open.result.close(); + resolve([]); + return; + } const all = open.result.transaction('accounts').objectStore('accounts').getAll(); all.onsuccess = () => { open.result.close(); @@ -103,11 +124,10 @@ async function serverDraftBodies(subject: string): Promise { return list.filter((email) => email.keywords.$draft).map((email) => Object.values(email.bodyValues).map((part) => part.value).join('\n')); } -/** Signed in with "Remember me", so a reload finds the session again. */ +/** On this rig the box isn't offered, and a login is remembered, so a reload finds the session again. */ async function signIn(page: Page): Promise { await page.fill('#username', ENT.alice); await page.fill('#password', ENT_PASSWORD); - await page.getByText('Remember me', { exact: true }).click(); await page.click('button[type="submit"]'); await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); } @@ -145,7 +165,7 @@ test('locked: no mail in the page, its memory gone, the store closed with no key expect(await heldLocks(page)).toContain('bulwark-store-owner'); expect(await heldLocks(page)).toContain('bulwark-app-lock-holder'); - await page.goto('/settings/general'); + await gotoUnlocked(page, '/settings/general'); await page.evaluate(() => { (window as unknown as { __before: string }).__before = 'still here'; }); await page.getByTestId('app-lock-now').click(); await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); @@ -205,7 +225,7 @@ test('all tabs lock together, and a new tab or pop-out address stays locked; the await second.goto('/mail'); await expect(second.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); - await page.goto('/settings/general'); + // This tab holds the key: no new load here, or the others would lock for that. await page.getByTestId('app-lock-now').click(); await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); await expect(lockForm(second)).toBeVisible({ timeout: 30_000 }); @@ -230,9 +250,10 @@ test('all tabs lock together, and a new tab or pop-out address stays locked; the await expect(third.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); // The key was never passed on: when the tab that has it goes, the others have to ask. + // Up to a store call's wait for the owner that went, then the reload. await second.close(); - await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); - await expect(lockForm(third)).toBeVisible({ timeout: 30_000 }); + await expect(lockForm(page)).toBeVisible({ timeout: 90_000 }); + await expect(lockForm(third)).toBeVisible({ timeout: 90_000 }); await unlock(page); await context.close(); }); @@ -273,7 +294,6 @@ test('an open composer\'s text is saved first, is unreadable while locked, and n test('time without use locks every tab, and use in another tab counts', async ({ browser }) => { const { page, context } = await fresh(browser); await enableLock(page); - await page.goto('/settings/general'); await page.getByTestId('app-lock-timeout').selectOption('1'); const other = await context.newPage(); await other.goto('/mail'); @@ -368,13 +388,34 @@ test('a login without "Remember me" has to sign in again after a lock, and its l await enableLock(page); const before = await keyRecords(page); expect(before.length).toBeGreaterThan(0); - await lockNow(page); - await page.getByTestId('app-lock-pin').fill(PIN); - await page.getByTestId('app-lock-unlock').click(); - await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + await page.getByTestId('app-lock-now').click(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + // As a login with the box left unticked is written down. This rig doesn't offer the box. + await page.evaluate(() => { + const registry = JSON.parse(localStorage.getItem('account-registry') ?? '{}'); + for (const account of registry.state.accounts) account.rememberMe = false; + localStorage.setItem('account-registry', JSON.stringify(registry)); + }); + await context.clearCookies(); + await page.reload(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await unlock(page); + // The way to the login page and back from it are full page loads, and each asks again. + const pastLock = async (target: ReturnType) => { + for (let attempt = 0; attempt < 4; attempt += 1) { + await expect(target.or(lockForm(page)).first()).toBeVisible({ timeout: 60_000 }); + if (await target.first().isVisible()) return; + await unlock(page); + } + throw new Error('still at the lock screen'); + }; + await pastLock(page.locator('#username')); // Not what a page reload does to such a login with the lock off: that one wipes. expect((await keyRecords(page)).map((record) => record.account)).toEqual(before.map((record) => record.account)); - await signIn(page); + await page.fill('#username', ENT.alice); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await pastLock(accountSwitcher(page)); await expect.poll(() => heldLocks(page), { timeout: 60_000 }).toContain('bulwark-store-owner'); await context.close(); }); @@ -391,8 +432,10 @@ test('turning it off needs the PIN and takes the layer off', async ({ browser }) await page.getByTestId('app-lock-dialog-save').click(); await expect(page.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 }); for (const record of await keyRecords(page)) expect(record.layers).toEqual([]); + // Off: a reload asks for nothing. await page.reload(); - await expect(accountSwitcher(page).or(page.getByTestId('app-lock-switch')).first()).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('app-lock-switch')).toBeVisible({ timeout: 60_000 }); + await expect(lockForm(page)).toHaveCount(0); await context.close(); }); diff --git a/lib/app-lock/vault.ts b/lib/app-lock/vault.ts index 92a751347..5b41b3915 100644 --- a/lib/app-lock/vault.ts +++ b/lib/app-lock/vault.ts @@ -257,6 +257,3 @@ export async function openSealedDrafts(lockKey: CryptoKey): Promise { }); return out; } - -export { keyFromPrf }; -export type { SealedAnswer }; From b94bf27b30cbcafa3d6a499430ab487f47723950 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:36:06 +0100 Subject: [PATCH 26/45] test: check that a locked main window renews no token and answers nothing --- .../popout/__tests__/popout-host.test.tsx | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index dcdb2e036..09f6824b8 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -4,7 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch import { setLockedHere } from '@/lib/app-lock/state'; import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { takeRescuedDraft } from '@/lib/popout/rescued-drafts'; -import { createSessionOpener } from '@/lib/popout/sealed'; +import { createSessionOpener, credentialFingerprint } from '@/lib/popout/sealed'; import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; @@ -133,6 +133,45 @@ describe('PopoutHost', () => { setLockedHere(false); popout.post({ type: 'session-request', requestId: 'r2', publicKey: opener.publicKey }); await vi.waitFor(() => expect(answers()).toHaveLength(1)); + expect(answers().map((message) => (message as { requestId: string }).requestId)).toEqual(['r2']); + }); + + it('renews no token for a pop-out while the app is locked', async () => { + const refresh = vi.fn(async () => 'next'); + useAuthStore.setState({ refreshAccessToken: refresh as never }); + render(); + await settle(); + setLockedHere(true); + try { + const opener = await createSessionOpener(); + const rejectedFingerprint = await credentialFingerprint('Bearer main'); + popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey, accountId: 'alice', rejectedFingerprint }); + await settle(40); + expect(refresh).not.toHaveBeenCalled(); + expect(answers()).toEqual([]); + } finally { + setLockedHere(false); + } + }); + + it('answers nothing when the lock comes during the renewal', async () => { + const refresh = vi.fn(async () => { + setLockedHere(true); + return 'next'; + }); + useAuthStore.setState({ refreshAccessToken: refresh as never }); + render(); + await settle(); + try { + const opener = await createSessionOpener(); + const rejectedFingerprint = await credentialFingerprint('Bearer main'); + popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey, accountId: 'alice', rejectedFingerprint }); + await vi.waitFor(() => expect(refresh).toHaveBeenCalled()); + await settle(40); + expect(answers()).toEqual([]); + } finally { + setLockedHere(false); + } }); it('a locked pop-out doesn\'t ask for a session', async () => { From fbda1e34017ea9662afc26b9478fb9fb80b37f7b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:40:09 +0100 Subject: [PATCH 27/45] fix: keep the Docker build within Node's default heap #1510 --- lib/__tests__/next-config-build-worker.test.ts | 11 +++++++++++ next.config.ts | 7 +++++++ 2 files changed, 18 insertions(+) create mode 100644 lib/__tests__/next-config-build-worker.test.ts diff --git a/lib/__tests__/next-config-build-worker.test.ts b/lib/__tests__/next-config-build-worker.test.ts new file mode 100644 index 000000000..286e0634f --- /dev/null +++ b/lib/__tests__/next-config-build-worker.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import config from '../../next.config'; + +describe('webpack build worker', () => { + // Next turns the workers off for a config with a webpack hook unless the + // option is set, and the Docker build then runs out of heap (#1510). + it('stays on although next-intl adds a webpack hook', () => { + expect(config.webpack).toBeTypeOf('function'); + expect(config.experimental?.webpackBuildWorker).toBe(true); + }); +}); diff --git a/next.config.ts b/next.config.ts index ba49d2cc2..eaea73d23 100644 --- a/next.config.ts +++ b/next.config.ts @@ -102,6 +102,13 @@ const nextConfig: NextConfig = { typescript: { tsconfigPath: "tsconfig.build.json", }, + // next-intl's plugin adds a webpack hook, and with one Next runs the client, + // server and edge compilers in its main process on one heap, which outgrew + // Node's default limit of about 2.2 GB (#1510). In workers each compiler has + // its own heap, freed when it finishes. Turbopack builds don't read this. + experimental: { + webpackBuildWorker: true, + }, // No proxyClientMaxBodySize: Next copies every body proxy.ts runs for up to // that size before routing, so it stays at the 10 MB default. The routes // that take bigger uploads (WOPI saves, WebDAV, /api/plugin-proxy) are left From a52a39f9e64e9d1586d3dc90c5623b4caaf59db2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:40:10 +0100 Subject: [PATCH 28/45] fix: keep one bad event from stopping reminders, offer Join for web links only, and keep summaries until they are seen --- .../calendar/missed-reminders-dialog.tsx | 12 +- .../use-store-reminders-review.test.tsx | 442 ++++++++++++++++++ hooks/__tests__/use-store-reminders.test.tsx | 5 +- hooks/use-calendar-alerts.ts | 42 +- hooks/use-store-reminders.ts | 78 +++- integration/tests/24-do-reminders.spec.ts | 2 +- .../__tests__/claims.test.ts | 50 +- .../__tests__/runner.test.ts | 90 +++- lib/calendar-reminders/claims.ts | 37 +- lib/calendar-reminders/runner.ts | 80 +++- lib/calendar-reminders/system-notification.ts | 9 +- .../__tests__/reminder-notifications.test.ts | 78 +++- lib/desktop/reminder-notifications.ts | 73 ++- lib/event-links.ts | 29 ++ lib/store/__tests__/reminders-review.test.ts | 405 ++++++++++++++++ lib/store/__tests__/reminders.test.ts | 3 +- lib/store/calendar/reminders.ts | 229 ++++++--- lib/store/reminders/index.ts | 17 +- lib/store/reminders/ledger.ts | 68 ++- lib/store/reminders/schema.ts | 4 + locales/ar/common.json | 4 +- locales/ca/common.json | 4 +- locales/cs/common.json | 4 +- locales/da/common.json | 4 +- locales/de/common.json | 4 +- locales/en/common.json | 4 +- locales/es/common.json | 4 +- locales/et/common.json | 4 +- locales/fa/common.json | 4 +- locales/fr/common.json | 4 +- locales/he/common.json | 4 +- locales/hu/common.json | 4 +- locales/it/common.json | 4 +- locales/ja/common.json | 4 +- locales/ko/common.json | 4 +- locales/lv/common.json | 4 +- locales/mn/common.json | 4 +- locales/nb/common.json | 4 +- locales/nl/common.json | 4 +- locales/pl/common.json | 4 +- locales/pt/common.json | 4 +- locales/ro/common.json | 4 +- locales/ru/common.json | 4 +- locales/sk/common.json | 4 +- locales/th/common.json | 4 +- locales/tr/common.json | 4 +- locales/uk/common.json | 4 +- locales/vi/common.json | 4 +- locales/zh-TW/common.json | 4 +- locales/zh/common.json | 4 +- public/sw.js | 5 +- 51 files changed, 1646 insertions(+), 232 deletions(-) create mode 100644 hooks/__tests__/use-store-reminders-review.test.tsx create mode 100644 lib/store/__tests__/reminders-review.test.ts diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx index 6309994fd..2b4ed8af3 100644 --- a/components/calendar/missed-reminders-dialog.tsx +++ b/components/calendar/missed-reminders-dialog.tsx @@ -5,7 +5,7 @@ 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, joinMeeting } from "@/lib/desktop/reminder-notifications"; +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. */ @@ -17,6 +17,7 @@ export function MissedRemindersDialog() { 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; @@ -58,8 +59,13 @@ export function MissedRemindersDialog() {

{alert.joinUrl && ( - )} diff --git a/hooks/__tests__/use-store-reminders-review.test.tsx b/hooks/__tests__/use-store-reminders-review.test.tsx new file mode 100644 index 000000000..dfdafce43 --- /dev/null +++ b/hooks/__tests__/use-store-reminders-review.test.tsx @@ -0,0 +1,442 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog'; +import { ToastContainer } from '@/components/ui/toast'; +import { useCalendarAlerts } from '../use-calendar-alerts'; +import { useStoreReminders } from '../use-store-reminders'; +import { claimKey, claimNow } from '@/lib/calendar-reminders/claims'; +import { useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state'; +import { setAppLockCheck } from '@/lib/desktop/app-lock-state'; +import type { BulwarkDesktopBridge, DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types'; +import { clearAllHooks, uiHooks } from '@/lib/plugin-hooks'; +import { useOfflineStore } from '@/lib/offline/state'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { setStoreBackend } from '@/lib/store/client/api'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarNotificationStore } from '@/stores/calendar-notification-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +const sounds = vi.hoisted(() => ({ play: vi.fn() })); +vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play })); +vi.mock('@/lib/title-alert', () => ({ startTitleAlert: vi.fn() })); +// Real crypto takes real time, and fake time would run ahead of it. +vi.mock('@/lib/pwa-notify/login-fingerprint', () => ({ loginFingerprint: async () => 'fingerprint' })); +vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true })); +const policy = vi.hoisted(() => ({ pushWithoutContent: false })); +vi.mock('@/lib/device-policy', async (original) => ({ + ...await original(), + devicesForAccount: () => ({ pushWithoutContent: policy.pushWithoutContent }), +})); + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; +const FOREIGN_LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://calls.example.org/room' } } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +let notifyResult: DesktopOsNotifyResult; +const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult); +const openExternal = vi.fn(); + +function desktop() { + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +function Toasts() { + const toasts = useToastStore((s) => s.toasts); + const removeToast = useToastStore((s) => s.removeToast); + return ; +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +async function syncedWith(...events: Record[]) { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title); + +/** The login fingerprint is real crypto, in real time. Fake time must not run ahead of it. */ +async function untilToast() { + for (let tries = 0; tries < 500 && toastTitles().length === 0; tries += 1) { + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + } +} + +/** Two alerts due with the app closed. Leaves the hook unmounted and the clock moved on. */ +async function missedTwo(...extra: Record[]) { + await syncedWith(event(extra[0] ?? {}), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); +} + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + vi.clearAllMocks(); + clearAllHooks(); + localStorage.clear(); + setAppLockCheck(null); + setVisibility('visible'); + policy.pushWithoutContent = false; + notifyResult = { shown: true, playSound: false }; + useToastStore.setState({ toasts: [] }); + useReminderSource.setState({ driven: {} }); + useMissedReminders.getState().close(); + useCalendarNotificationStore.getState().clearAll(); + useOfflineStore.setState({ enabled: [ACCOUNT] }); + useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT }); + useCalendarStore.setState({ events: [], calendars: [] }); + usePolicyStore.setState({ loaded: true, policy: { ...usePolicyStore.getState().policy, features: { ...usePolicyStore.getState().policy.features, calendarEnabled: true } } }); + useAccountStore.setState({ accounts: [{ id: ACCOUNT, cookieSlot: 1, serverUrl: 'https://one.example', username: 'a@example.org' }] as never }); + useEmailStore.setState({ isPushConnected: true }); + useSettingsStore.setState({ + calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true, + doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin', + }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.unstubAllGlobals(); + vi.useRealTimers(); + Reflect.deleteProperty(navigator, 'serviceWorker'); +}); + +function grantNotifications() { + const showNotification = vi.fn(async (_title: string, _options: NotificationOptions) => {}); + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' })); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) }, + }); + return showNotification; +} + +describe('Join', () => { + beforeEach(desktop); + + it('says the host of a link to a service nobody knows', async () => { + await syncedWith(event(FOREIGN_LINK)); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + const join = notify.mock.calls[0][0].actions!.find((action) => action.id === 'join')!; + expect(join.label).toBe('join_host'); + }); + + it('asks the link check, and a stopped link opens nothing and leaves the reminder standing', async () => { + await syncedWith(event(FOREIGN_LINK)); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + uiHooks.onBeforeExternalLink.register('t', () => false); + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + await untilToast(); + const dismiss = vi.spyOn(store, 'area'); + fireEvent.click(screen.getByText('join_host')); + await advance(100); + expect(openExternal).not.toHaveBeenCalled(); + expect(dismiss.mock.calls.filter(([, name]) => name === 'reminders.dismiss')).toEqual([]); + }); + + it('goes out through the app when the link check lets it', async () => { + await syncedWith(event(FOREIGN_LINK)); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + await untilToast(); + const area = vi.spyOn(store, 'area'); + fireEvent.click(screen.getByText('join_host')); + await advance(100); + expect(openExternal).toHaveBeenCalledWith('https://calls.example.org/room'); + expect(area.mock.calls.filter(([, name]) => name === 'reminders.dismiss')).toHaveLength(1); + }); + + it('names each Join in the summary for a screen reader', async () => { + await missedTwo(FOREIGN_LINK); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(<>); + renderHook(() => useStoreReminders()); + await advance(20_000); + await untilToast(); + fireEvent.click(screen.getByText('missed_show')); + await advance(0); + expect(screen.getByText('join_host').getAttribute('aria-label')).toBe('join_named'); + }); +}); + +describe('with pushWithoutContent', () => { + it('keeps the title off a browser system notification, on the store path', async () => { + policy.pushWithoutContent = true; + const shown = grantNotifications(); + setVisibility('hidden'); + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(shown).toHaveBeenCalledTimes(1); + expect(shown.mock.calls[0][0]).toBe('generic_title'); + expect(shown.mock.calls[0][1].body).toBe(''); + expect(toastTitles()).toEqual(['Standup']); + }); + + it('keeps the titles off the summary as well', async () => { + policy.pushWithoutContent = true; + await missedTwo(); + const shown = grantNotifications(); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(20_000 + 3000); + expect(shown).toHaveBeenCalledTimes(1); + expect(JSON.stringify(shown.mock.calls[0])).not.toContain('Standup'); + }); + + it('keeps it off on the path without a store', async () => { + policy.pushWithoutContent = true; + useOfflineStore.setState({ enabled: [] }); + const shown = grantNotifications(); + setVisibility('hidden'); + useCalendarStore.setState({ + events: [{ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now() + 3000); + expect(shown).toHaveBeenCalledTimes(1); + expect(shown.mock.calls[0][0]).toBe('alert_title'); + expect(shown.mock.calls[0][1].body).toBe(''); + }); +}); + +describe('when the OS refuses or fails', () => { + beforeEach(desktop); + + it('a rejected notify keeps a reminder for the summary', async () => { + await syncedWith(event()); + notify.mockRejectedValueOnce(new Error('main is gone')); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + await advance(5 * MIN); + expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/); + }); + + it('a failed toast keeps a reminder for the summary', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'failed', playSound: false }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + notifyResult = { shown: true, playSound: false }; + await advance(5 * MIN); + expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/); + expect(notify.mock.calls.at(-1)![0].body).toBe('Standup'); + }); + + it('a rejected notify puts a summary straight back', async () => { + await missedTwo(); + notify.mockRejectedValueOnce(new Error('main is gone')); + renderHook(() => useStoreReminders()); + await advance(20_000); + expect(notify).toHaveBeenCalledTimes(1); + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + }); +}); + +describe('a summary in the app', () => { + beforeEach(desktop); + + it('comes back after a reload that lost the toast', async () => { + await missedTwo(); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + const page = renderHook(() => useStoreReminders()); + await advance(20_000); + await untilToast(); + expect(toastTitles()).toEqual(['missed_title']); + + // A reload: the toast and the page are gone, nobody dismissed anything. + page.unmount(); + useToastStore.setState({ toasts: [] }); + renderHook(() => useStoreReminders()); + await advance(10 * MIN); + expect(toastTitles()).toEqual(['missed_title']); + }); + + it('stays one toast for as long as it is on screen', async () => { + await missedTwo(); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + renderHook(() => useStoreReminders()); + await advance(20_000); + await untilToast(); + await advance(30 * MIN); + expect(toastTitles()).toEqual(['missed_title']); + }); + + it('does not come back after it was closed', async () => { + await missedTwo(); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(); + const page = renderHook(() => useStoreReminders()); + await advance(20_000); + await untilToast(); + act(() => useToastStore.getState().toasts[0].onDismiss!()); + useToastStore.setState({ toasts: [] }); + await advance(30 * MIN); + page.unmount(); + renderHook(() => useStoreReminders()); + await advance(30 * MIN); + expect(toastTitles()).toEqual([]); + }); + + it('leaves out what the path without a store already rang', async () => { + await missedTwo(); + claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE); + renderHook(() => useStoreReminders()); + await advance(20_000); + expect(notify.mock.calls[0][0].body).toBe('Review'); + }); + + it('says nothing at all when that was all of it', async () => { + await missedTwo(); + claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE); + claimNow(claimKey(ACCOUNT, 'uid-2', 'a', FIRE), FIRE); + renderHook(() => useStoreReminders()); + await advance(20_000 + 10 * MIN); + expect(notify).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + }); +}); + +describe('the organisation has the calendar off', () => { + beforeEach(() => { + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, features: { ...usePolicyStore.getState().policy.features, calendarEnabled: false } } }); + }); + + it('the store path stays quiet, and nothing counts as missed later', async () => { + desktop(); + await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(notify).not.toHaveBeenCalled(); + await advance(20 * MIN); + expect(notify).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + }); + + it('the path without a store stays quiet', async () => { + useOfflineStore.setState({ enabled: [] }); + useCalendarStore.setState({ + events: [{ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now() + MIN); + expect(toastTitles()).toEqual([]); + }); +}); + +describe('the path without a store on the desktop', () => { + const plain = () => useCalendarStore.setState({ + events: [{ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + + beforeEach(() => { + desktop(); + useOfflineStore.setState({ enabled: [] }); + plain(); + }); + + it('stands down while locked: nothing fires, nothing is used up', async () => { + let locked = true; + setAppLockCheck(() => locked); + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now() + MIN); + expect(notify).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + + locked = false; + await advance(MIN); + expect(toastTitles()).toEqual(['Standup']); + }); + + it('sends an id that names nothing when the title is hidden', async () => { + policy.pushWithoutContent = true; + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now()); + expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('e1'); + expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup'); + }); + + it('plays no sound in quiet hours or a pause', async () => { + notifyResult = { shown: false, reason: 'quiet-hours', playSound: false }; + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].sound).toBe(true); + expect(sounds.play).not.toHaveBeenCalled(); + }); + + it('plays the sound main asks for', async () => { + notifyResult = { shown: true, playSound: true }; + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now()); + expect(sounds.play).toHaveBeenCalledTimes(1); + }); +}); + +describe('the click on a browser notification', () => { + it('carries the address in the user language', async () => { + const shown = grantNotifications(); + setVisibility('hidden'); + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect((shown.mock.calls[0][1].data as { url: string }).url).toMatch(/\/calendar$/); + }); +}); + +describe('without a push channel', () => { + it('syncs every few minutes, so an event made elsewhere still rings', async () => { + useEmailStore.setState({ isPushConnected: false }); + await syncedWith(); + const sync = vi.spyOn(store, 'sync'); + renderHook(() => useStoreReminders()); + await advance(11 * MIN); + expect(sync.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('leaves it to the push channel when there is one', async () => { + useEmailStore.setState({ isPushConnected: true }); + await syncedWith(); + const sync = vi.spyOn(store, 'sync'); + renderHook(() => useStoreReminders()); + await advance(11 * MIN); + expect(sync).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/__tests__/use-store-reminders.test.tsx b/hooks/__tests__/use-store-reminders.test.tsx index afcad0f8a..229b8ab80 100644 --- a/hooks/__tests__/use-store-reminders.test.tsx +++ b/hooks/__tests__/use-store-reminders.test.tsx @@ -22,6 +22,8 @@ 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) => ({ @@ -155,6 +157,7 @@ describe('in the desktop app', () => { 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'); }); @@ -333,7 +336,7 @@ describe('in a browser', () => { await advance(FIRE - Date.now() + 3000); expect(toastTitles()).toEqual(['Standup']); expect(shown).toHaveBeenCalledTimes(1); - expect(shown).toHaveBeenCalledWith('Standup', expect.objectContaining({ data: { kind: 'calendar-reminder' }, silent: false })); + 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 () => { diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts index 365647480..60a4a38c1 100644 --- a/hooks/use-calendar-alerts.ts +++ b/hooks/use-calendar-alerts.ts @@ -6,6 +6,7 @@ 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'; @@ -20,7 +21,9 @@ 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 { 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'; @@ -84,10 +87,11 @@ export function useCalendarAlerts() { 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() && !fromStore; + 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); @@ -102,12 +106,24 @@ export function useCalendarAlerts() { const checkAlertsRef = useRef<() => void>(() => {}); // Beside the toast: the desktop app's native notification, a browser's system one. - const tellOutsideTheWindow = useCallback((key: string, title: string, body: string, silent: boolean) => { + 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) { - notifyWithoutStore(`reminder:${key}`, title, body, { generic: t('alert_title') }, activeAccountId); + // 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; } - void showSystemReminder({ key, title, body, silent }, `${getPathPrefix(locale)}/${locale}/calendar`); + 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(() => { @@ -116,6 +132,8 @@ export function useCalendarAlerts() { 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(); @@ -154,10 +172,6 @@ export function useCalendarAlerts() { eventTitle: alert.event.title || '', triggerTime: new Date(alert.fireTimeMs).toISOString(), }, () => { - const sound = reminderSoundOn(calendarNotificationSound); - if (sound) { - playNotificationSound(notificationSoundChoice); - } const title = alert.event.title || t('alert_title'); reminderTitleAlert(t('title_alert', { title })); addToast({ @@ -172,7 +186,7 @@ export function useCalendarAlerts() { void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId }); }, }); - tellOutsideTheWindow(key, title, message, !sound); + tellOutsideTheWindow(key, alert.fireTimeMs, title, message, calendarNotificationSound); }); }); } @@ -199,10 +213,6 @@ export function useCalendarAlerts() { taskTitle: taskAlert.task.title || '', triggerTime: new Date(taskAlert.fireTimeMs).toISOString(), }, () => { - const sound = reminderSoundOn(calendarNotificationSound); - if (sound) { - playNotificationSound(notificationSoundChoice); - } const title = taskAlert.task.title || t('alert_title'); reminderTitleAlert(t('title_alert', { title })); addToast({ @@ -217,7 +227,7 @@ export function useCalendarAlerts() { void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId }); }, }); - tellOutsideTheWindow(key, title, taskMsg, !sound); + tellOutsideTheWindow(key, taskAlert.fireTimeMs, title, taskMsg, calendarNotificationSound); }); }); } @@ -226,7 +236,7 @@ export function useCalendarAlerts() { // Silently ignore alert evaluation errors } }, [ - calendarNotificationsEnabled, calendarNotificationSound, notificationSoundChoice, + calendarNotificationsEnabled, calendarNotificationSound, isAuthenticated, activeAccountId, events, calendars, acknowledgedAlerts, acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, tellOutsideTheWindow, ]); diff --git a/hooks/use-store-reminders.ts b/hooks/use-store-reminders.ts index adeb0468b..3c5d0c437 100644 --- a/hooks/use-store-reminders.ts +++ b/hooks/use-store-reminders.ts @@ -5,16 +5,18 @@ 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, claimNow } from '@/lib/calendar-reminders/claims'; +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, @@ -32,9 +34,14 @@ 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' @@ -75,6 +82,7 @@ export function useStoreReminders(): void { 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 }), }; @@ -88,6 +96,7 @@ export function useStoreReminders(): void { useEffect(() => { if (!signedIn || isPopoutWindow()) return; const toastsByKey = new Map(); + const loans = new Set>(); const inAppReminder = (account: string, alert: ReminderAlert) => { const current = labelsRef.current; @@ -120,7 +129,16 @@ export function useStoreReminders(): void { secondaryAction: ten, action: fifteen, extraActions: [ - ...(alert.joinUrl ? [{ label: current.join, onClick: () => joinMeeting(alert.joinUrl!) }] : []), + ...(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; } }, ], } : {}), @@ -135,9 +153,9 @@ export function useStoreReminders(): void { 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)); - // Paused, quiet hours, or nothing may be said now: the summary says it later. - if (locked || result.reason === 'paused' || result.reason === 'quiet-hours') { + 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; } @@ -155,22 +173,32 @@ export function useStoreReminders(): void { 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 batch = await getStore().area(account, 'reminders.takeMissed', { timeZone }); - if (!batch) return; + const taken = await getStore().area(account, 'reminders.takeMissed', { timeZone }); + if (!taken) return; + // What the path without a store already rang, before the store took over, was seen. + const rung = taken.alerts.filter((alert) => firedWithoutStore(claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs))); + if (rung.length > 0) await getStore().area(account, 'reminders.dismiss', { keys: rung.map((alert) => alert.key) }); + const batch: MissedBatch = { batch: taken.batch, alerts: taken.alerts.filter((alert) => !rung.includes(alert)) }; + if (batch.alerts.length === 0) return; const current = labelsRef.current; const os = getDesktopBridge()?.os; if (os) { - const result = await os.notify(await buildMissedNotification(account, batch, current)); - if (result.shown) return; + 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.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'duplicate') { - await getStore().area(account, 'reminders.releaseMissed', { batch: batch.batch }); + 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; } } @@ -195,16 +223,34 @@ export function useStoreReminders(): void { }, }); 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(), @@ -213,16 +259,17 @@ export function useStoreReminders(): void { return { timeZone: reminderTimeZone(settings.timeZone), tasks: settings.enableCalendarTasks, - muted: !settings.calendarNotificationsEnabled, + 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) { - // Whichever of the two paths got there first: one comes back from a snooze. - const key = claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs); - if (!claimNow(key, alert.fireTimeMs) && !alert.snoozedUntil) continue; + // 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); @@ -259,6 +306,7 @@ export function useStoreReminders(): void { return () => { runner.stop(); + for (const loan of loans) clearInterval(loan); runnerRef.current = null; setReminderPoke(null); stopEvents(); diff --git a/integration/tests/24-do-reminders.spec.ts b/integration/tests/24-do-reminders.spec.ts index 19699dcce..44976d7e6 100644 --- a/integration/tests/24-do-reminders.spec.ts +++ b/integration/tests/24-do-reminders.spec.ts @@ -166,7 +166,7 @@ test('adds a system notification when permission is granted and the tab is not i }, title); expect(shown).toHaveLength(1); expect(shown[0].tag).toContain('calendar-reminder:'); - expect(shown[0].data).toEqual({ kind: 'calendar-reminder' }); + expect(shown[0].data).toMatchObject({ kind: 'calendar-reminder', url: expect.stringMatching(/\/calendar$/) }); await context.clearPermissions(); }); diff --git a/lib/calendar-reminders/__tests__/claims.test.ts b/lib/calendar-reminders/__tests__/claims.test.ts index 862738746..7e60f078c 100644 --- a/lib/calendar-reminders/__tests__/claims.test.ts +++ b/lib/calendar-reminders/__tests__/claims.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { claimAlert, claimKey, claimNow, CLAIMS_STORAGE_KEY, HIDDEN_GRACE_MS } from '../claims'; +import { claimAlert, claimKey, claimNow, CLAIMS_STORAGE_KEY, firedWithoutStore, HIDDEN_GRACE_MS, noteStoreFired } from '../claims'; const FIRE = Date.UTC(2026, 10, 10, 9); @@ -50,6 +50,20 @@ describe('claimNow', () => { 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 })); @@ -57,16 +71,46 @@ describe('claimNow', () => { }); describe('claimAlert', () => { - it('lets one of two tabs through when their timers run out together', async () => { - Object.defineProperty(navigator, 'locks', { configurable: true, value: fakeLocks() }); + it('reads and writes the list only with the lock held, one tab at a time', async () => { + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const asked: string[] = []; + let queue: Promise = held; + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request: (name: string, callback: () => T | Promise) => { + asked.push(name); + const run = queue.then(callback); + queue = run; + return run; + }, + }, + }); const won = vi.fn(); claimAlert('k', FIRE, () => won('tab 1')); claimAlert('k', FIRE, () => won('tab 2')); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Another tab has the lock: nothing has looked at the list yet. + expect(localStorage.getItem(CLAIMS_STORAGE_KEY)).toBeNull(); + expect(won).not.toHaveBeenCalled(); + expect(asked).toEqual(['bulwark-calendar-alert-claim', 'bulwark-calendar-alert-claim']); + + release(); await vi.waitFor(() => expect(won).toHaveBeenCalled()); await new Promise((resolve) => setTimeout(resolve, 0)); expect(won.mock.calls).toEqual([['tab 1']]); }); + it('still rings when the lock fails, rather than losing the reminder', async () => { + Object.defineProperty(navigator, 'locks', { configurable: true, value: { request: () => Promise.reject(new Error('Locks are off in this context')) } }); + const won = vi.fn(); + claimAlert('k', FIRE, won); + await vi.waitFor(() => expect(won).toHaveBeenCalledTimes(1)); + }); + it('runs at once where there is no Web Lock', () => { const won = vi.fn(); claimAlert('k', FIRE, won); diff --git a/lib/calendar-reminders/__tests__/runner.test.ts b/lib/calendar-reminders/__tests__/runner.test.ts index fd45f0ad4..a9639f899 100644 --- a/lib/calendar-reminders/__tests__/runner.test.ts +++ b/lib/calendar-reminders/__tests__/runner.test.ts @@ -37,6 +37,7 @@ function tab(options: Partial> = {}): Tab { accounts: () => [ACCOUNT], store: () => store, params: () => ({ timeZone: 'Europe/Berlin', tasks: true, muted: made.muted }), + needsPolling: () => false, hidden: () => made.hidden, offline: () => made.offline, onDue: (_account, alerts) => { @@ -45,7 +46,9 @@ function tab(options: Partial> = {}): Tab { }, onMissed: async (account) => { const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' }); - if (batch) made.missed.push(batch); + if (!batch) return; + made.missed.push(batch); + await store.area(account, 'reminders.settleMissed', { batch: batch.batch }); }, onSource: (_account, driven) => { made.source.push(driven); @@ -175,6 +178,91 @@ describe('firing from the store', () => { }); }); +describe('a tab out of view', () => { + it('waits before any check, also after a wake, so the tab in view gets the alert', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + const visible = tab(); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS); + visible.runner.start(); + await vi.advanceTimersByTimeAsync(0); + vi.setSystemTime(FIRE + MIN); + hidden.runner.wake(); + visible.runner.wake(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS); + expect(hidden.due).toEqual([]); + expect(visible.due).toHaveLength(1); + }); + + it('waits on a poke and on a sync too', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS); + const area = vi.spyOn(store, 'area'); + hidden.runner.poke(); + hidden.runner.synced(ACCOUNT); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS - 1); + expect(area).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(area).toHaveBeenCalledTimes(1); + }); + + it('a stream of pokes cannot put it off for ever', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS); + const area = vi.spyOn(store, 'area'); + for (let i = 0; i < 5; i += 1) { + hidden.runner.poke(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS / 2); + } + expect(area).toHaveBeenCalled(); + }); +}); + +describe('going down in the middle', () => { + it('keeps what the store just handed over when the page stopped meanwhile', async () => { + await syncedStore(event()); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1); + const area = store.area.bind(store); + vi.spyOn(store, 'area').mockImplementationOnce(async (account: string, name: string, params?: unknown) => { + const result = await area(account, name, params); + one.runner.stop(); + return result as never; + }); + await vi.advanceTimersByTimeAsync(1); + expect(one.due).toEqual([]); + // Not lost: it waits for the summary. + await vi.advanceTimersByTimeAsync(0); + const next = tab({ offline: true }); + next.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(next.missed.flatMap((batch) => batch.alerts.map((alert) => alert.title))).toEqual(['Standup']); + }); + + it('checks again after showing one threw', async () => { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review', start: '2026-11-10T10:05:00' })); + const one = tab(); + const plain = one.env.onDue; + let first = true; + one.env.onDue = (account, alerts) => { + if (first) { + first = false; + throw new Error('A plugin threw'); + } + plain(account, alerts); + }; + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + 5 * MIN); + expect(one.due.map((alert) => alert.title)).toEqual(['Review']); + }); +}); + describe('after being away', () => { async function awayFromBothAlerts(): Promise { await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); diff --git a/lib/calendar-reminders/claims.ts b/lib/calendar-reminders/claims.ts index 069660aea..d50dc4035 100644 --- a/lib/calendar-reminders/claims.ts +++ b/lib/calendar-reminders/claims.ts @@ -6,11 +6,16 @@ * * 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; @@ -27,10 +32,7 @@ function readClaims(): Record { } } -/** True for the first to ask. Also what the store path tells the other path with. */ -export function claimNow(key: string, fireTimeMs: number): boolean { - const claims = readClaims(); - if (key in claims) return false; +function writeClaim(claims: Record, key: string, fireTimeMs: number): void { const now = Date.now(); const kept = Object.fromEntries(Object.entries(claims).filter(([, fire]) => now - fire < KEEP_MS)); kept[key] = fireTimeMs; @@ -39,20 +41,43 @@ export function claimNow(key: string, fireTimeMs: number): boolean { } 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) { - if (claimNow(key, fireTimeMs)) won(); + 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(); diff --git a/lib/calendar-reminders/runner.ts b/lib/calendar-reminders/runner.ts index 26bb9a569..876f85ade 100644 --- a/lib/calendar-reminders/runner.ts +++ b/lib/calendar-reminders/runner.ts @@ -2,7 +2,8 @@ * 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. + * 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. @@ -25,11 +26,13 @@ export const SUMMARY_WAIT_MS = 20_000; export interface ReminderRunnerEnv { /** The local accounts with a store right now. */ accounts(): string[]; - store(): Pick; + store(): Pick; params(): TickParams; /** Out of view: another tab in view should get to go first. */ hidden(): boolean; offline(): boolean; + /** No push channel to say the calendar changed, so the store has to go and look. */ + needsPolling(account: string): boolean; /** Runs in the one tab that got these. */ onDue(account: string, alerts: ReminderAlert[]): void; /** There's a summary to give. Resolves when that's been tried. */ @@ -51,6 +54,7 @@ export interface ReminderRunner { export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { let timer: ReturnType | null = null; + let timerAt = 0; let clockWatch: ReturnType | null = null; let lastWatch = 0; let running = false; @@ -59,11 +63,31 @@ export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { let summaryAt = 0; const presenting = new Set(); const known = new Set(); + const lastPoll = new Map(); const schedule = (at: number) => { if (timer) clearTimeout(timer); - const wait = Math.max(at - Date.now(), 0) + (env.hidden() ? HIDDEN_GRACE_MS : 0); - timer = setTimeout(() => void run(), wait); + 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 => { @@ -72,7 +96,14 @@ export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { result = await env.store().area(account, 'reminders.tick', env.params()); } catch { // The store is out of reach: the old path takes these over until it's back. - env.onSource(account, false); + 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); @@ -97,23 +128,30 @@ export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { } running = true; let next = Date.now() + REFRESH_MS; - const accounts = env.accounts(); - for (const gone of [...known]) { - if (!accounts.includes(gone)) { - known.delete(gone); - env.onSource(gone, false); + 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; } - for (const account of accounts) { - known.add(account); - const at = await check(account); - if (at !== null) next = Math.min(next, at); - } - running = false; if (stopped) return; if (again) { again = false; - void run(); + request(); return; } schedule(next); @@ -130,7 +168,7 @@ export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { lastWatch = now; if (jumped) this.wake(); }, CLOCK_WATCH_MS); - void run(); + request(); }, stop() { stopped = true; @@ -142,15 +180,15 @@ export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { known.clear(); }, poke() { - void run(); + request(); }, wake() { summaryAt = Date.now() + SUMMARY_WAIT_MS; - void run(); + request(); }, synced() { summaryAt = 0; - void run(); + request(); }, }; } diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts index 538669c1b..2427f7c19 100644 --- a/lib/calendar-reminders/system-notification.ts +++ b/lib/calendar-reminders/system-notification.ts @@ -11,6 +11,8 @@ export interface SystemReminder { 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 { @@ -37,14 +39,15 @@ export function wantsSystemNotification(): boolean { export async function showSystemReminder(reminder: SystemReminder, openUrl: string): Promise { if (!wantsSystemNotification()) return; - const options = { body: reminder.body, tag: tagOf(reminder.key), silent: reminder.silent }; + 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(reminder.title, { ...options, data: { kind: 'calendar-reminder' } }); + await worker.showNotification(title, { ...options, data: { kind: 'calendar-reminder', url: openUrl } }); return; } - const shown = new Notification(reminder.title, options); + const shown = new Notification(title, options); shown.onclick = () => { window.focus(); window.location.href = openUrl; diff --git a/lib/desktop/__tests__/reminder-notifications.test.ts b/lib/desktop/__tests__/reminder-notifications.test.ts index 1a19e29d2..f609dae3b 100644 --- a/lib/desktop/__tests__/reminder-notifications.test.ts +++ b/lib/desktop/__tests__/reminder-notifications.test.ts @@ -9,6 +9,7 @@ import { buildMissedNotification, buildReminderNotification, handleReminderCommand, + joinMeeting, missedBody, notifyWithoutStore, type ReminderLabels, @@ -19,7 +20,8 @@ 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 }) } })); +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 }) })); @@ -36,6 +38,7 @@ const labels: ReminderLabels = { snoozeShort: (minutes) => `${minutes} min`, open: 'Open', join: 'Join', + joinHost: (host) => `Join (${host})`, missedTitle: (count) => `${count} missed`, missedMore: (count) => `and ${count} more`, }; @@ -43,7 +46,7 @@ const labels: ReminderLabels = { function alert(partial: Partial = {}): ReminderAlert { return { key: `j1:uid-1:a:${FIRE}`, jmap: 'j1', uid: 'uid-1', kind: 'event', baseEventId: 'e1', recurrenceId: null, alertId: 'a', fireTimeMs: FIRE, - title: 'Secret board meeting', start: '2026-11-10T10:00:00', startMs: START, joinUrl: null, timeZone: 'Europe/Berlin', calendarId: 'cal1', calendarName: 'Personal', + title: 'Secret board meeting', start: '2026-11-10T10:00:00', startMs: START, joinUrl: null, joinHost: null, timeZone: 'Europe/Berlin', calendarId: 'cal1', calendarName: 'Personal', ...partial, }; } @@ -89,6 +92,9 @@ describe('what a reminder says', () => { 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 () => { @@ -135,11 +141,70 @@ describe('what a reminder says', () => { }); it('without a store says what it can, and nothing when locked', () => { - notifyWithoutStore('id', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT); - expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Dentist', body: 'In 5 min', context: '{"cal":true}' })); + 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('id2', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT); - expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'GENERIC', body: '' })); + notifyWithoutStore({ key: 'e1:a:1', fireTimeMs: 1, title: 'Dentist', body: 'In 5 min', sound: true }, { generic: 'GENERIC' }, ACCOUNT); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls[1]).toEqual([expect.objectContaining({ title: 'GENERIC', body: '' })]); + expect(JSON.stringify(notify.mock.calls[1])).not.toContain('Dentist'); + expect(JSON.stringify(notify.mock.calls[1])).not.toContain('e1'); + }); +}); + +describe('sizes', () => { + it('keeps the context under what main will take, with the longest key the store makes', async () => { + const key = `${'j'.repeat(40)}:#abc:9999:${'a'.repeat(64)}:${FIRE}`; + const { context } = await buildReminderNotification(ACCOUNT, alert({ key, uid: 'u'.repeat(5000) }), labels, false); + expect(context.length).toBeLessThan(1024); + expect(JSON.parse(context).r).toBe(key); + }); +}); + +describe('Join is only ever a web link', () => { + for (const url of ['ms-msdt:/id PCWDiagnostic', 'search-ms:query=x', 'smb://evil.example/share', 'https://meet.google.com@evil.example/x', 'javascript:alert(1)', 'not a link']) { + it(`opens nothing for ${url}, in the app`, async () => { + expect(await joinMeeting(url)).toBe(false); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it(`opens nothing for ${url}, in a browser`, async () => { + window.bulwarkDesktop = undefined; + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + expect(await joinMeeting(url)).toBe(false); + expect(open).not.toHaveBeenCalled(); + open.mockRestore(); + }); + } + + it('opens a web link, in the app and in a browser', async () => { + expect(await joinMeeting('https://meet.jit.si/x')).toBe(true); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/x'); + window.bulwarkDesktop = undefined; + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + expect(await joinMeeting('https://meet.jit.si/y')).toBe(true); + expect(open).toHaveBeenCalledWith('https://meet.jit.si/y', '_blank', 'noopener,noreferrer'); + open.mockRestore(); + }); + + it('leaves the reminder standing when Join from a toast opened nothing', async () => { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } }, start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE); + const [reminder] = (await store.area(ACCOUNT, 'reminders.tick', params)).due; + const { uiHooks } = await import('@/lib/plugin-hooks'); + const stop = uiHooks.onBeforeExternalLink.register('t', () => false); + const area = vi.spyOn(store, 'area'); + await handleReminderCommand('join', await contextFor(reminder), env); + stop.dispose(); + expect(openExternal).not.toHaveBeenCalled(); + expect(area.mock.calls.filter(([, name]) => name === 'reminders.dismiss')).toEqual([]); + // The window comes up, since the button did nothing else. + expect(focus).toHaveBeenCalled(); }); }); @@ -231,6 +296,7 @@ describe('what its buttons do', () => { 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); diff --git a/lib/desktop/reminder-notifications.ts b/lib/desktop/reminder-notifications.ts index 3979c1d96..f2840e58f 100644 --- a/lib/desktop/reminder-notifications.ts +++ b/lib/desktop/reminder-notifications.ts @@ -12,8 +12,10 @@ 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 } from '@/lib/desktop/types'; -import { devicesForAccount } from '@/lib/device-policy'; +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'; @@ -38,6 +40,8 @@ export interface ReminderLabels { 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; } @@ -59,13 +63,8 @@ async function loginOf(account: string): Promise, labels: Pick): string { + return alert.joinHost ? labels.joinHost(alert.joinHost) : labels.join; } function reminderId(alert: ReminderAlert): string { @@ -80,11 +79,11 @@ export async function buildReminderNotification(account: string, alert: Reminder const context: ReminderToastContext = { r: alert.key, ...(await loginOf(account)) }; const short = getDesktopBridge()?.platform === 'win32'; const actions = [ - ...(alert.joinUrl ? [{ id: JOIN, label: labels.join }] : []), + ...(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 = hideContent(account); + const hidden = contentHiddenFor(account); return { ...base, id: reminderId(alert), @@ -109,22 +108,36 @@ export async function buildMissedNotification(account: string, batch: MissedBatc group: 'reminders', kind: 'reminder', title: labels.missedTitle(batch.alerts.length), - body: hideContent(account) ? '' : missedBody(batch.alerts, labels), + body: contentHiddenFor(account) ? '' : missedBody(batch.alerts, labels), context: JSON.stringify(context), }; } -/** Without a store: says so, with nothing to snooze. A click goes to the calendar. */ -export function notifyWithoutStore(id: string, title: string, body: string, labels: { generic: string }, account: string | null): void { - const hidden = isAppLocked() || !account || hideContent(account); - void getDesktopBridge()?.os?.notify({ - id, +export interface PlainReminder { + key: string; + fireTimeMs: number; + title: string; + body: string; + sound: boolean; +} + +/** + * Without a store: says so, with nothing to snooze. A click goes to the calendar. + * Resolves to main's answer, which also says whether a sound is in order. + */ +export function notifyWithoutStore(reminder: PlainReminder, labels: { generic: string }, account: string | null): Promise { + const hidden = contentHiddenFor(account); + const request = getDesktopBridge()?.os?.notify({ + // The key holds the event's id, and nothing about the event should get out now. + id: hidden ? `reminder-hidden:${reminder.fireTimeMs}` : `reminder:${reminder.key}`, group: 'reminders', kind: 'reminder', - title: hidden ? labels.generic : title, - body: hidden ? '' : body, + 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 { @@ -141,10 +154,19 @@ export function calendarPathFor(account: string, alert: ReminderAlert | null): s return `/calendar/event/${encodeURIComponent(alert.baseEventId)}${query}`; } -export function joinMeeting(url: string): void { +/** + * Opens a meeting the way every link in the app opens, past any link check a plugin has. + * Resolves true when it opened. Only web links get this far, whatever the store holds. + */ +export async function joinMeeting(url: string): Promise { + const link = joinLinkFrom(url); + if (!link) return false; const shell = getDesktopBridge()?.shell; - if (shell) shell.openExternal(url); - else window.open(url, '_blank', 'noopener,noreferrer'); + 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 { @@ -203,8 +225,11 @@ export async function handleReminderCommand(action: string, rawContext: string, const alert = account && key ? await getStore().area(account, 'reminders.get', { key }).catch(() => null) : null; if (action === JOIN && alert?.joinUrl) { - joinMeeting(alert.joinUrl); - dismissReminders(account!, [alert.key]); + if (await joinMeeting(alert.joinUrl)) { + dismissReminders(account!, [alert.key]); + } else { + show(); + } return true; } diff --git a/lib/event-links.ts b/lib/event-links.ts index 8c350c37d..5f1f8f12b 100644 --- a/lib/event-links.ts +++ b/lib/event-links.ts @@ -115,6 +115,35 @@ export function findMeetingLink(event: EventPlaces): MeetingLink | null { return null; } +export interface JoinLink { + url: string; + /** Set for a link to no service this app knows: say where it goes before anyone clicks. */ + host: string | null; +} + +/** + * The address to offer as Join: web links only, with no user name in them, since + * `https://meet.google.com@evil.example` goes to evil.example. An app scheme can start + * anything on the machine (ms-msdt:, search-ms:, smb:), and this comes from whoever sent + * the invitation. + */ +export function joinLinkFrom(uri: string): JoinLink | null { + let parsed: URL; + try { + parsed = new URL(uri.trim()); + } catch { + return null; + } + if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || parsed.username || parsed.password || !parsed.hostname) return null; + return { url: parsed.href, host: meetingProviderOf(parsed.href) ? null : parsed.hostname }; +} + +/** The event's Join, when it has one that's safe to offer. */ +export function findJoinLink(event: EventPlaces): JoinLink | null { + const link = findMeetingLink(event); + return link ? joinLinkFrom(link.uri) : null; +} + /** The first location's display name, if any. */ export function primaryLocationName(event: Pick): string | undefined { const name = Object.values(event.locations ?? {})[0]?.name?.trim(); diff --git a/lib/store/__tests__/reminders-review.test.ts b/lib/store/__tests__/reminders-review.test.ts new file mode 100644 index 000000000..ab834a7e8 --- /dev/null +++ b/lib/store/__tests__/reminders-review.test.ts @@ -0,0 +1,405 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE } from '../area'; +import { calendarArea } from '../calendar'; +import { listUpcomingAlerts } from '../calendar/reminders'; +import { instantOf } from '../calendar/records'; +import { AccountEngine } from '../engine/account-engine'; +import { remindersArea } from '../reminders'; +import { AWAY_MS, type MissedBatch, type TickResult } from '../reminders/ledger'; +import { CALENDAR_SESSION, FakeCalendarJmap } from './helpers/fake-calendar'; +import { JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const MIN = 60_000; +const DAY = 24 * 60 * MIN; +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 WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; +const ZONE = 'Europe/Berlin'; +const PARAMS = { timeZone: ZONE, tasks: true }; + +function setup(startAt = FIRE - 60 * MIN) { + const server = new FakeCalendarJmap('1.0'); + const db = openMemoryStore(); + const rig = { server, db, now: startAt }; + const make = () => new AccountEngine({ + account: 'a@fake.test', + db, + session: CALENDAR_SESSION, + transport: server, + areas: [calendarArea, remindersArea], + scope: DEFAULT_SCOPE, + now: () => rig.now, + sleep: async () => {}, + }); + let engine = make(); + return Object.assign(rig, { + get engine() { + return engine; + }, + restart: () => { + engine = make(); + }, + call: (name: string, params?: unknown) => engine.areaCall(name, params) as Promise, + tick: (params: Record = {}) => engine.areaCall('reminders.tick', { ...PARAMS, ...params }) as Promise, + }); +} + +type Rig = ReturnType; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: ZONE, duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +async function syncedAndWatching(rig: Rig): Promise { + await rig.engine.requestSync(); + await rig.tick(); +} + +/** The last check just before the alert, so nothing counts as away. */ +async function justBefore(rig: Rig): Promise { + rig.now = FIRE - MIN; + await rig.tick(); +} + +describe('one bad event', () => { + const BAD: Array<[string, Record]> = [ + ['an offset out of all range', { alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: 'P999999999D' } } } }], + ['a huge negative offset on a series', { recurrenceRule: WEEKLY, start: '2026-10-06T10:00:00', alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P999999999W' } } } }], + ['an alert without a trigger', { alerts: { a: { '@type': 'Alert', action: 'display' } } }], + ['a null alert', { alerts: { a: null } }], + ['a null alert on a series', { recurrenceRule: WEEKLY, start: '2026-10-06T10:00:00', alerts: { a: null } }], + ['a start that is no date', { start: 'yesterday-ish' }], + ]; + + for (const [name, partial] of BAD) { + it(`doesn't stop the others: ${name}`, async () => { + const rig = setup(); + rig.server.addEvent(event({ id: 'bad', uid: 'bad', title: 'Bad', ...partial })); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due.map((alert) => alert.title)).toEqual(['Standup']); + }); + } + + it("doesn't stop the others: a row that can't be read at all", async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + rig.db.run('INSERT INTO calendar_event (jmap, id, uid, json) VALUES (?, ?, ?, ?)', ['a-other', 'broken', 'broken', '{not json']); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due.map((alert) => alert.title)).toEqual(['Standup']); + }); + + it('keeps the good alert of an event with a bad one', async () => { + const rig = setup(); + rig.server.addEvent(event({ alerts: { bad: null, huge: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P999999999W' } }, a: ALERT } })); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due.map((alert) => alert.alertId)).toEqual(['a']); + }); +}); + +describe('Join', () => { + const OPTIONS = { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE }; + + async function joinOf(uri: string, extra: Record = {}) { + const rig = setup(); + rig.server.addEvent(event({ virtualLocations: { v: { '@type': 'VirtualLocation', uri } }, ...extra })); + await rig.engine.requestSync(); + const [alert] = listUpcomingAlerts(rig.db, OPTIONS); + return alert; + } + + for (const uri of ['ms-msdt:/id PCWDiagnostic', 'search-ms:query=x', 'smb://evil.example/share', 'msteams:/l/meetup-join/x', 'https://meet.google.com@evil.example/abc-defg-hij', 'javascript:alert(1)']) { + it(`offers none for ${uri}`, async () => { + expect((await joinOf(uri)).joinUrl).toBeNull(); + }); + } + + it('names the host of a link it does not know, not of one it does', async () => { + expect(await joinOf('https://calls.example.org/room')).toMatchObject({ joinUrl: 'https://calls.example.org/room', joinHost: 'calls.example.org' }); + expect(await joinOf('https://meet.jit.si/standup')).toMatchObject({ joinUrl: 'https://meet.jit.si/standup', joinHost: null }); + expect(await joinOf('https://meet.google.com/abc-defg-hij')).toMatchObject({ joinHost: null }); + }); +}); + +describe('shared calendars', () => { + function addOtherCopy(rig: Rig, jmap: string, json: Record) { + rig.db.run('INSERT INTO calendar_event (jmap, id, uid, json) VALUES (?, ?, ?, ?)', [jmap, json.id as string, json.uid as string, JSON.stringify({ calendarIds: { cal1: true }, '@type': 'Event', ...json })]); + } + + it('leaves out a calendar the user has not subscribed to', async () => { + const rig = setup(); + rig.server.calendars.get('cal1')!.isSubscribed = false; + rig.server.addEvent(event()); + await rig.engine.requestSync(); + expect(listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE })).toEqual([]); + }); + + it('still reads a calendar that says nothing about it', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await rig.engine.requestSync(); + expect(listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE })).toHaveLength(1); + }); + + it('rings once for a meeting in two accounts, from the own account', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await rig.engine.requestSync(); + addOtherCopy(rig, 'a-shared', event({ id: 'other', alerts: { other: ALERT } })); + const alerts = listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE, prefer: JMAP_ACCOUNT }); + expect(alerts.map((alert) => alert.jmap)).toEqual([JMAP_ACCOUNT]); + const other = listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE, prefer: 'a-shared' }); + expect(other.map((alert) => alert.jmap)).toEqual(['a-shared']); + }); + + it('prefers the account the session names as primary when it fires', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + addOtherCopy(rig, 'a-shared', event({ id: 'other' })); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due.map((alert) => alert.jmap)).toEqual([JMAP_ACCOUNT]); + }); +}); + +describe('an edit that keeps the time', () => { + it('does not fire again when only the alert id changed', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due).toHaveLength(1); + rig.server.patchEvent('e1', { alerts: { renamed: ALERT } }); + await rig.engine.requestSync(); + rig.now = FIRE + MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('keeps the snooze, and brings it back once', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + const [fired] = (await rig.tick()).due; + await rig.call('reminders.snooze', { key: fired.key, minutes: 5 }); + rig.server.patchEvent('e1', { alerts: { renamed: ALERT } }); + await rig.engine.requestSync(); + rig.now = FIRE + 5 * MIN; + expect((await rig.tick()).due.map((alert) => alert.alertId)).toEqual(['renamed']); + rig.now += MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('a move while snoozed: the old time is forgotten, the new one rings', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + const [fired] = (await rig.tick()).due; + await rig.call('reminders.snooze', { key: fired.key, minutes: 5 }); + rig.server.patchEvent('e1', { start: '2026-11-10T10:30:00' }); + await rig.engine.requestSync(); + rig.now = FIRE + 5 * MIN; + expect((await rig.tick()).due).toEqual([]); + rig.now = FIRE + 10 * MIN; + await rig.tick(); + rig.now = FIRE + 15 * MIN; + await rig.tick(); + rig.now = FIRE + 20 * MIN; + await rig.tick(); + rig.now = FIRE + 25 * MIN; + await rig.tick(); + rig.now = FIRE + 30 * MIN; + const moved = (await rig.tick()).due; + expect(moved.map((alert) => alert.fireTimeMs)).toEqual([FIRE + 30 * MIN]); + }); +}); + +describe('zones', () => { + it('reads an all-day series in the Time zone setting, in any zone', async () => { + for (const zone of ['Pacific/Auckland', 'America/Los_Angeles', 'UTC']) { + const rig = setup(); + rig.server.addEvent(event({ + start: '2026-10-06T00:00:00', + showWithoutTime: true, + timeZone: null, + duration: 'P1D', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } }, + })); + await rig.engine.requestSync(); + const startOfDay = instantOf('2026-11-10T00:00:00', zone)!; + const alerts = listUpcomingAlerts(rig.db, { from: startOfDay - 2 * 60 * MIN, to: startOfDay + MIN, timeZone: zone }); + expect(alerts.map((alert) => alert.fireTimeMs)).toEqual([startOfDay - 60 * MIN]); + expect(alerts[0].startMs).toBe(startOfDay); + } + }); +}); + +describe('a fixed-time alert on a series', () => { + it('rings as before', async () => { + const rig = setup(); + rig.server.addEvent(event({ + start: '2026-10-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } }, + })); + await rig.engine.requestSync(); + expect(listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE })).toHaveLength(1); + }); + + it('stays quiet when the occurrence it was for was excluded', async () => { + const rig = setup(); + rig.server.addEvent(event({ + start: '2026-10-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } }, + recurrenceOverrides: { '2026-11-10T10:00:00': { excluded: true } }, + })); + await rig.engine.requestSync(); + expect(listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE })).toEqual([]); + }); +}); + +describe('the edge of being away', () => { + async function twoAlerts(gapMs: number): Promise { + const rig = setup(); + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + await rig.engine.requestSync(); + rig.now = FIRE - 1000; + await rig.tick(); + rig.now = FIRE - 1000 + gapMs; + return rig.tick(); + } + + it('a gap of exactly the limit is no absence', async () => { + expect(await twoAlerts(AWAY_MS)).toMatchObject({ missed: 0, due: [{ title: 'Standup' }, { title: 'Review' }] }); + }); + + it('a gap a moment longer is', async () => { + expect(await twoAlerts(AWAY_MS + 1)).toMatchObject({ missed: 2, due: [] }); + }); + + it('a single alert past the limit is a late reminder, not a summary', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await rig.engine.requestSync(); + rig.now = FIRE - 1000; + await rig.tick(); + rig.now = FIRE - 1000 + AWAY_MS + 1; + expect(await rig.tick()).toMatchObject({ missed: 0, due: [{ title: 'Standup' }] }); + }); +}); + +describe('pruning', () => { + it('forgets what is past the reach, and keeps what still waits', async () => { + const rig = setup(); + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Snoozed' })); + rig.server.addEvent(event({ id: 'e3', uid: 'uid-3', title: 'Held' })); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + const fired = (await rig.tick()).due; + const byTitle = Object.fromEntries(fired.map((alert) => [alert.title, alert.key])); + await rig.call('reminders.snooze', { key: byTitle.Snoozed, minutes: 60 }); + await rig.call('reminders.hold', { keys: [byTitle.Held] }); + const rows = () => rig.db.all<{ key: string }>('SELECT key FROM reminder').map((row) => row.key).sort(); + expect(rows()).toHaveLength(3); + rig.now = FIRE + 9 * DAY; + await rig.tick(); + expect(rows()).toEqual([byTitle.Snoozed, byTitle.Held].sort()); + }); +}); + +describe('bounded keys', () => { + it('stays short with a very long uid, and tells two long ones apart', async () => { + const rig = setup(); + const long = 'u'.repeat(5000); + rig.server.addEvent(event({ uid: `${long}1` })); + rig.server.addEvent(event({ id: 'e2', uid: `${long}2` })); + await rig.engine.requestSync(); + const alerts = listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE }); + expect(alerts).toHaveLength(2); + expect(new Set(alerts.map((alert) => alert.key)).size).toBe(2); + for (const alert of alerts) expect(alert.key.length).toBeLessThan(300); + }); +}); + +describe('a summary on loan', () => { + async function missedTwo(rig: Rig): Promise { + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE + 60 * MIN; + await rig.tick(); + return rig.call('reminders.takeMissed', PARAMS); + } + + it('comes back when whoever took it never said it was shown: a crash, a reload', async () => { + const rig = setup(); + const batch = await missedTwo(rig); + expect(batch.alerts).toHaveLength(2); + rig.now += MIN; + expect((await rig.tick()).missed).toBe(0); + rig.now += 5 * MIN; + expect((await rig.tick()).missed).toBe(2); + }); + + it('stays while the window showing it keeps saying so', async () => { + const rig = setup(); + const batch = await missedTwo(rig); + for (let i = 0; i < 6; i += 1) { + rig.now += MIN; + await rig.call('reminders.renewMissed', { batch: batch.batch }); + expect((await rig.tick()).missed).toBe(0); + } + expect(await rig.call('reminders.batch', { batch: batch.batch })).toHaveLength(2); + }); + + it('is done once a native notification showed it, and can still open later', async () => { + const rig = setup(); + const batch = await missedTwo(rig); + await rig.call('reminders.settleMissed', { batch: batch.batch }); + rig.now += 60 * MIN; + expect((await rig.tick()).missed).toBe(0); + expect(await rig.call('reminders.batch', { batch: batch.batch })).toHaveLength(2); + }); + + it('does not come back once dismissed', async () => { + const rig = setup(); + const batch = await missedTwo(rig); + await rig.call('reminders.dismiss', { keys: batch.alerts.map((alert) => alert.key) }); + rig.now += 60 * MIN; + expect((await rig.tick()).missed).toBe(0); + }); +}); + +describe('muted', () => { + it('asks for no summary of what already waited', async () => { + const rig = setup(); + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE + 60 * MIN; + expect((await rig.tick()).missed).toBe(2); + rig.now += MIN; + expect((await rig.tick({ muted: true })).missed).toBe(0); + }); +}); diff --git a/lib/store/__tests__/reminders.test.ts b/lib/store/__tests__/reminders.test.ts index 16cf60d2f..8974b9050 100644 --- a/lib/store/__tests__/reminders.test.ts +++ b/lib/store/__tests__/reminders.test.ts @@ -231,8 +231,9 @@ describe('away', () => { const rig = setup(); rig.server.addEvent(event()); await synced(rig); - rig.now = FIRE - MIN; + rig.now = FIRE - 2 * MIN; await rig.tick(); + // Over the limit for being away, and under ten minutes old. rig.now = FIRE + 5 * MIN; expect(await rig.tick()).toMatchObject({ due: [{ title: 'Standup' }], missed: 0 }); }); diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts index e293d1618..80f431328 100644 --- a/lib/store/calendar/reminders.ts +++ b/lib/store/calendar/reminders.ts @@ -12,12 +12,12 @@ import { format, parseISO } from 'date-fns'; import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts'; import { isTaskLikeObject } from '@/lib/calendar-component-detection'; import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; -import { findMeetingLink, type EventPlaces } from '@/lib/event-links'; +import { findJoinLink, type EventPlaces } from '@/lib/event-links'; import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { listObjects } from '../engine/core-schema'; import type { StorageAdapter } from '../storage'; -import { instantsOf, isSeries, type CalendarObject } from './records'; +import { instantOf, instantsOf, isSeries, type CalendarObject } from './records'; export interface UpcomingAlert { /** Stable for this alert at this time. Use it to remember what was shown. */ @@ -37,8 +37,10 @@ export interface UpcomingAlert { start: string | null; /** The start as an instant, to say how long until it. Null when it can't be told. */ startMs: number | null; - /** Joins the meeting, when the event has a video link. */ + /** Joins the meeting, when the event has a video link. Web links only. */ joinUrl: string | null; + /** Where that goes, when it's no service this app knows. */ + joinHost: string | null; timeZone: string | null; calendarId: string | null; calendarName: string | null; @@ -51,23 +53,68 @@ export interface UpcomingAlertsOptions { to: number; /** Reads floating events. The user's zone. */ timeZone: string; + /** Which account's copy to use for a meeting that's in several. */ + prefer?: string; } type AlertMap = Record | null | undefined; +/** Anyone asking to be reminded further off than this has made a typing error, and expanding that far has no end. */ +const LONGEST_OFFSET_MS = 366 * 24 * 3_600_000; + // An alert fires up to its offset away from its occurrence, and the occurrence's // wall clock is up to 26 hours from the instant. Expand wide, filter by instant. function offsetReachMs(alerts: AlertMap): number { let reach = 0; for (const alert of Object.values(alerts ?? {})) { - if (alert.trigger?.['@type'] === 'AbsoluteTrigger') continue; - reach = Math.max(reach, Math.abs(parseAlertOffset(alert.trigger?.offset ?? '') ?? 0)); + if (!alert?.trigger || alert.trigger['@type'] === 'AbsoluteTrigger') continue; + const offset = Math.abs(parseAlertOffset(alert.trigger.offset ?? '') ?? 0); + if (offset <= LONGEST_OFFSET_MS) reach = Math.max(reach, offset); } return reach; } +/** What can fire at all. The server keeps whatever a client wrote, so none of this is a given. */ +function usableAlerts(alerts: AlertMap): Array<[string, CalendarEventAlert]> { + return Object.entries(alerts ?? {}).filter(([, alert]) => { + if (!alert?.trigger || alert.action !== 'display') return false; + if (alert.trigger['@type'] === 'AbsoluteTrigger') return true; + return Math.abs(parseAlertOffset(alert.trigger.offset ?? '') ?? Infinity) <= LONGEST_OFFSET_MS; + }); +} + +/** Keeps keys, and the toast that hands one back, a sensible size. Not for secrecy. */ +function bounded(value: string, limit: number): string { + if (value.length <= limit) return value; + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + h1 = Math.imul(h1 ^ code, 2654435761); + h2 = Math.imul(h2 ^ code, 1597334677); + } + return `#${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}:${value.length}`; +} + +/** + * A fixed time on a series is for the occurrence that follows it. When that one was + * excluded there's nothing to remind of, and no later occurrence takes it over. + */ +function fixedAlertForExcluded(master: CalendarEvent, fireTimeMs: number, occurrences: CalendarEvent[], zone: string): boolean { + const excluded = Object.entries(master.recurrenceOverrides ?? {}) + .filter(([, override]) => (override as { excluded?: boolean }).excluded) + .map(([at]) => instantOf(at, master.timeZone ?? zone)) + .filter((at): at is number => at !== null && at >= fireTimeMs); + if (excluded.length === 0) return false; + const nextExcluded = Math.min(...excluded); + return !occurrences.some((occurrence) => { + const at = instantOf(occurrence.start, occurrence.timeZone ?? zone); + return at !== null && at >= fireTimeMs && at < nextExcluded; + }); +} + function alertsInOverrides(event: CalendarEvent): AlertMap[] { - return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap }).alerts); + return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap } | null)?.alerts); } const ZONE_REACH_MS = 27 * 3_600_000; @@ -112,84 +159,116 @@ function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: strin return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null }; } -/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. */ +/** + * Someone else's calendar on 1.0 is unsubscribed until the user adds it, and an account + * can be shared without anyone asking for its reminders. Says nothing: it counts. + */ +function inSubscribedCalendar(calendarIds: unknown, calendars: Calendar[]): boolean { + const ids = Object.keys((calendarIds ?? {}) as Record); + return ids.length === 0 || ids.some((id) => calendars.find((calendar) => calendar.id === id)?.isSubscribed !== false); +} + +function matchOf(alert: Pick): string { + return `${alert.uid}:${alert.fireTimeMs}`; +} + +/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. One event that can't be read is left out, not the rest. */ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOptions): UpcomingAlert[] { + // By uid and time, not by account: one meeting in the own and a shared calendar rings once. const found = new Map(); const add = (alert: UpcomingAlert) => { - if (alert.fireTimeMs >= options.from && alert.fireTimeMs < options.to) found.set(alert.key, alert); + if (alert.fireTimeMs < options.from || alert.fireTimeMs >= options.to) return; + const match = matchOf(alert); + const held = found.get(match); + if (!held || (alert.jmap === options.prefer && held.jmap !== options.prefer)) found.set(match, alert); }; const rows = db.all<{ jmap: string; json: string }>('SELECT jmap, json FROM calendar_event WHERE hidden = 0'); const calendarsOf = new Map(); for (const row of rows) { if (!calendarsOf.has(row.jmap)) calendarsOf.set(row.jmap, listObjects(db, row.jmap, 'Calendar')); - const calendars = calendarsOf.get(row.jmap)!; - const object = JSON.parse(row.json) as CalendarObject; - const calendar = firstCalendar(object.calendarIds, calendars); - // With the account: Stalwart's ids repeat across accounts, and one meeting can be in two. - const uid = typeof object.uid === 'string' && object.uid ? object.uid : object.id; - const stableId = `${row.jmap}:${uid}`; - - if (isTaskLikeObject(object)) { - const task = object as unknown as CalendarTask; - if (task.progress === 'completed' || task.progress === 'cancelled') continue; - for (const [alertId, alert] of Object.entries(task.alerts ?? {})) { - if (alert.action !== 'display' || alert.acknowledged) continue; - const fireTimeMs = computeTaskFireTime(task, alert.trigger); - if (fireTimeMs === null) continue; - add({ - key: buildAlertKey(stableId, alertId, fireTimeMs), - jmap: row.jmap, - uid, - kind: 'task', - baseEventId: object.id, - recurrenceId: null, - alertId, - fireTimeMs, - title: task.title ?? '', - start: task.start ?? task.due ?? null, - startMs: instant(task.start ?? task.due), - joinUrl: findMeetingLink(task as unknown as EventPlaces)?.uri ?? null, - timeZone: task.timeZone ?? null, - calendarId: calendar.id, - calendarName: calendar.name, - }); - } - continue; - } - - const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent); - if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) continue; - const series = isSeries(object); - const occurrences = series ? occurrencesNear(master, calendars, options) : [master]; - // A fixed time doesn't need an occurrence nearby. - const candidates = series ? [master, ...occurrences] : occurrences; - for (const occurrence of candidates) { - if (occurrence.status === 'cancelled') continue; - const isMasterOfSeries = series && occurrence === master; - for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) { - if (alert.action !== 'display') continue; - if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; - const fireTimeMs = computeFireTime(occurrence, alert.trigger); - if (fireTimeMs === null || acknowledgedAlready(alert, fireTimeMs, series)) continue; - add({ - key: buildAlertKey(stableId, alertId, fireTimeMs), - jmap: row.jmap, - uid, - kind: 'event', - baseEventId: object.id, - recurrenceId: occurrence.recurrenceId ?? null, - alertId, - fireTimeMs, - title: occurrence.title ?? '', - start: occurrence.start, - startMs: instant(occurrence.utcStart) ?? instant(occurrence.start), - joinUrl: findMeetingLink(occurrence as unknown as EventPlaces)?.uri ?? null, - timeZone: occurrence.timeZone ?? null, - calendarId: calendar.id, - calendarName: calendar.name, - }); - } + try { + addAlertsOf(row, calendarsOf.get(row.jmap)!, options, add); + } catch { + // Whatever is wrong with this one, the others still have to ring. } } return [...found.values()].sort((a, b) => a.fireTimeMs - b.fireTimeMs || a.key.localeCompare(b.key)); } + +function addAlertsOf(row: { jmap: string; json: string }, calendars: Calendar[], options: UpcomingAlertsOptions, add: (alert: UpcomingAlert) => void): void { + const object = JSON.parse(row.json) as CalendarObject; + if (!inSubscribedCalendar(object.calendarIds, calendars)) return; + const calendar = firstCalendar(object.calendarIds, calendars); + const uid = typeof object.uid === 'string' && object.uid ? object.uid : object.id; + // With the account: Stalwart's ids repeat across accounts. + const stableId = `${row.jmap}:${bounded(uid, 200)}`; + + if (isTaskLikeObject(object)) { + const task = object as unknown as CalendarTask; + if (task.progress === 'completed' || task.progress === 'cancelled') return; + for (const [alertId, alert] of usableAlerts(task.alerts)) { + if (alert.acknowledged) continue; + const fireTimeMs = computeTaskFireTime(task, alert.trigger); + if (fireTimeMs === null) continue; + const join = findJoinLink(task as unknown as EventPlaces); + add({ + key: buildAlertKey(stableId, bounded(alertId, 64), fireTimeMs), + jmap: row.jmap, + uid, + kind: 'task', + baseEventId: object.id, + recurrenceId: null, + alertId, + fireTimeMs, + title: task.title ?? '', + start: task.start ?? task.due ?? null, + startMs: instant(task.start ?? task.due), + joinUrl: join?.url ?? null, + joinHost: join?.host ?? null, + timeZone: task.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + return; + } + + const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent); + if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) return; + const series = isSeries(object); + // Expansion reads an all-day or floating occurrence in this machine's zone. The setting decides. + const occurrences = series + ? occurrencesNear(master, calendars, options).map((occurrence) => withInstants(occurrence as unknown as CalendarObject, options.timeZone) as unknown as CalendarEvent) + : [master]; + // A fixed time doesn't need an occurrence nearby. + const candidates = series ? [master, ...occurrences] : occurrences; + for (const occurrence of candidates) { + if (occurrence.status === 'cancelled') continue; + const isMasterOfSeries = series && occurrence === master; + for (const [alertId, alert] of usableAlerts(getEffectiveAlerts(occurrence, calendars))) { + if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; + const fireTimeMs = computeFireTime(occurrence, alert.trigger); + if (fireTimeMs === null || acknowledgedAlready(alert, fireTimeMs, series)) continue; + if (series && alert.trigger['@type'] === 'AbsoluteTrigger' && fixedAlertForExcluded(master, fireTimeMs, occurrences, options.timeZone)) continue; + const join = findJoinLink(occurrence as unknown as EventPlaces); + add({ + key: buildAlertKey(stableId, bounded(alertId, 64), fireTimeMs), + jmap: row.jmap, + uid, + kind: 'event', + baseEventId: object.id, + recurrenceId: occurrence.recurrenceId ?? null, + alertId, + fireTimeMs, + title: occurrence.title ?? '', + start: occurrence.start, + startMs: instant(occurrence.utcStart) ?? instant(occurrence.start), + joinUrl: join?.url ?? null, + joinHost: join?.host ?? null, + timeZone: occurrence.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + } +} diff --git a/lib/store/reminders/index.ts b/lib/store/reminders/index.ts index 337a16b38..28d27e738 100644 --- a/lib/store/reminders/index.ts +++ b/lib/store/reminders/index.ts @@ -1,16 +1,25 @@ -import type { StoreArea } from '../area'; -import { dismiss, holdFired, readBatch, readReminder, releaseMissed, snooze, takeMissed, tick, type TickParams } from './ledger'; +import type { StoreArea, StoreContext } from '../area'; +import { dismiss, holdFired, readBatch, readReminder, releaseMissed, renewMissed, settleMissed, snooze, takeMissed, tick, type TickParams } from './ledger'; import { REMINDER_MIGRATIONS } from './schema'; +const CALENDARS = 'urn:ietf:params:jmap:calendars'; + +/** For a meeting in the own and a shared calendar: the own copy. */ +function primaryAccount(ctx: Pick): string | undefined { + return ctx.session.primaryAccounts[CALENDARS]; +} + export const remindersArea: StoreArea = { name: 'reminders', migrations: REMINDER_MIGRATIONS, methods: {}, syncers: [], calls: { - 'reminders.tick': (ctx, params: TickParams) => tick(ctx.db, ctx.now(), params), - 'reminders.takeMissed': (ctx, params: { timeZone: string }) => takeMissed(ctx.db, ctx.now(), params), + 'reminders.tick': (ctx, params: TickParams) => tick(ctx.db, ctx.now(), { ...params, prefer: primaryAccount(ctx) }), + 'reminders.takeMissed': (ctx, params: { timeZone: string }) => takeMissed(ctx.db, ctx.now(), { ...params, prefer: primaryAccount(ctx) }), 'reminders.releaseMissed': (ctx, params: { batch: number }) => releaseMissed(ctx.db, ctx.now(), params.batch), + 'reminders.renewMissed': (ctx, params: { batch: number }) => renewMissed(ctx.db, ctx.now(), params.batch), + 'reminders.settleMissed': (ctx, params: { batch: number }) => settleMissed(ctx.db, ctx.now(), params.batch), 'reminders.batch': (ctx, params: { batch: number }) => readBatch(ctx.db, params.batch), 'reminders.hold': (ctx, params: { keys: string[] }) => holdFired(ctx.db, ctx.now(), params.keys), 'reminders.snooze': (ctx, params: { key: string; minutes: number }) => snooze(ctx.db, ctx.now(), params.key, params.minutes), diff --git a/lib/store/reminders/ledger.ts b/lib/store/reminders/ledger.ts index 3f82a4fb6..35b81ea33 100644 --- a/lib/store/reminders/ledger.ts +++ b/lib/store/reminders/ledger.ts @@ -10,7 +10,8 @@ * - `snoozed`: comes back at `snooze_until`. * - `missed`: waits for a summary, because the device was away or notifications were * paused. - * - `summarised`: went into the summary `batch`. + * - `summarised`: went into the summary `batch`. On loan until `lease_until` is cleared, + * so a crash or a reload before anyone saw it brings it back. * - `dismissed`: the user is done with it. * * None of this goes to the server. JSCalendar's `acknowledged` is read (reminders.ts), @@ -32,6 +33,8 @@ export const LOOK_AHEAD_MS = 6 * 3_600_000; /** Rows older than the reach can't come up again. */ const KEEP_MS = MISSED_REACH_MS + 24 * 3_600_000; const CURSOR = 'reminder_cursor'; +/** How long a summary stays with whoever took it, unless they keep saying it's on screen. */ +export const LEASE_MS = 3 * 60_000; const LONGEST_SNOOZE_MIN = 24 * 60; export interface ReminderAlert extends UpcomingAlert { @@ -46,6 +49,8 @@ export interface TickParams { tasks: boolean; /** Reminders are switched off: time passes, and nothing counts as missed later. */ muted?: boolean; + /** The account's own copy of a meeting that's in several. */ + prefer?: string; } export interface TickResult { @@ -61,6 +66,7 @@ export interface TickResult { interface Row { key: string; + uid: string; status: string; fire_time: number; snooze_until: number | null; @@ -69,9 +75,9 @@ interface Row { function put(db: StorageAdapter, alert: ReminderAlert, status: string, now: number): void { db.run( - `INSERT INTO reminder (key, status, fire_time, snooze_until, batch, alert, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?) - ON CONFLICT (key) DO UPDATE SET status = excluded.status, snooze_until = NULL, batch = NULL, alert = excluded.alert, updated_at = excluded.updated_at`, - [alert.key, status, alert.fireTimeMs, JSON.stringify(alert), now], + `INSERT INTO reminder (key, uid, status, fire_time, snooze_until, batch, lease_until, alert, updated_at) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, ?) + ON CONFLICT (key) DO UPDATE SET status = excluded.status, snooze_until = NULL, batch = NULL, lease_until = NULL, alert = excluded.alert, updated_at = excluded.updated_at`, + [alert.key, alert.uid, status, alert.fireTimeMs, JSON.stringify(alert), now], ); } @@ -87,9 +93,15 @@ function calendarReady(db: StorageAdapter): boolean { return !!db.get("SELECT 1 AS yes FROM sync_state WHERE type = 'CalendarEvent' AND state IS NOT NULL LIMIT 1"); } +/** The same reminder to the user: an edit can give an alert a new id without moving it. */ +function sameReminder(uid: string, fireTime: number): string { + return `${uid}:${fireTime}`; +} + /** Whether a snoozed alert still stands: the event may have gone, moved or been dismissed elsewhere. */ -function alertNow(db: StorageAdapter, row: Pick, timeZone: string): UpcomingAlert | undefined { - return listUpcomingAlerts(db, { from: row.fire_time, to: row.fire_time + 1, timeZone }).find((alert) => alert.key === row.key); +function alertNow(db: StorageAdapter, row: Pick, params: Pick): UpcomingAlert | undefined { + return listUpcomingAlerts(db, { from: row.fire_time, to: row.fire_time + 1, timeZone: params.timeZone, prefer: params.prefer }) + .find((alert) => alert.uid === row.uid); } /** @@ -102,19 +114,21 @@ export function tick(db: StorageAdapter, now: number, params: TickParams): TickR const raw = metaGet(db, CURSOR); const cursor = raw === null ? null : Number(raw); metaSet(db, CURSOR, String(now)); - if (params.muted) return { due: [], missed: missedCount(db), nextAt: null, ready: calendarReady(db) }; + db.run("UPDATE reminder SET status = 'missed', batch = NULL, lease_until = NULL WHERE status = 'summarised' AND lease_until IS NOT NULL AND lease_until <= ?", [now]); + if (params.muted) return { due: [], missed: 0, nextAt: null, ready: calendarReady(db) }; const freshFrom = now - FRESH_MS; const from = cursor === null ? freshFrom : Math.max(Math.min(cursor, freshFrom), now - MISSED_REACH_MS); - const listed = listUpcomingAlerts(db, { from, to: now + LOOK_AHEAD_MS, timeZone: params.timeZone }) + const listed = listUpcomingAlerts(db, { from, to: now + LOOK_AHEAD_MS, timeZone: params.timeZone, prefer: params.prefer }) .filter((alert) => params.tasks || alert.kind !== 'task'); const past = listed.filter((alert) => alert.fireTimeMs <= now); const known = new Set( past.length === 0 ? [] - : db.all<{ key: string }>('SELECT key FROM reminder WHERE fire_time >= ? AND fire_time <= ?', [past[0].fireTimeMs, past[past.length - 1].fireTimeMs]).map((row) => row.key), + : db.all<{ uid: string; fire_time: number }>('SELECT uid, fire_time FROM reminder WHERE fire_time >= ? AND fire_time <= ?', [past[0].fireTimeMs, past[past.length - 1].fireTimeMs]) + .map((row) => sameReminder(row.uid, row.fire_time)), ); - const unseen = past.filter((alert) => !known.has(alert.key)); + const unseen = past.filter((alert) => !known.has(sameReminder(alert.uid, alert.fireTimeMs))); const away = cursor === null || now - cursor > AWAY_MS; const lastSeen = cursor ?? freshFrom; @@ -133,13 +147,15 @@ export function tick(db: StorageAdapter, now: number, params: TickParams): TickR } } - const expired = db.all("SELECT key, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'snoozed' AND snooze_until <= ?", [now]); + const expired = db.all("SELECT key, uid, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'snoozed' AND snooze_until <= ?", [now]); for (const row of expired) { - const alert = alertNow(db, row, params.timeZone); + const alert = alertNow(db, row, params); if (!alert) { db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, updated_at = ? WHERE key = ?", [now, row.key]); continue; } + // The alert got a new id: its row goes on under that. + if (alert.key !== row.key) db.run('DELETE FROM reminder WHERE key = ?', [row.key]); if (now - row.snooze_until! < FRESH_MS) { const back: ReminderAlert = { ...alert, snoozedUntil: row.snooze_until! }; put(db, back, 'fired', now); @@ -168,24 +184,24 @@ export interface MissedBatch { * The summary, for one caller only. What's gone from the calendar since doesn't come * back to haunt anyone. Null: nothing to say. */ -export function takeMissed(db: StorageAdapter, now: number, params: { timeZone: string }): MissedBatch | null { +export function takeMissed(db: StorageAdapter, now: number, params: { timeZone: string; prefer?: string }): MissedBatch | null { return db.transaction(() => { - const rows = db.all("SELECT key, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'missed' ORDER BY fire_time, key"); + const rows = db.all("SELECT key, uid, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'missed' ORDER BY fire_time, key"); if (rows.length === 0) return null; const standing = new Map( - listUpcomingAlerts(db, { from: rows[0].fire_time, to: rows[rows.length - 1].fire_time + 1, timeZone: params.timeZone }) - .map((alert) => [alert.key, alert]), + listUpcomingAlerts(db, { from: rows[0].fire_time, to: rows[rows.length - 1].fire_time + 1, timeZone: params.timeZone, prefer: params.prefer }) + .map((alert) => [sameReminder(alert.uid, alert.fireTimeMs), alert]), ); const newest = db.get<{ batch: number | null }>('SELECT MAX(batch) AS batch FROM reminder')?.batch ?? 0; const batch = Math.max(now, newest + 1); const alerts: ReminderAlert[] = []; for (const row of rows) { - const alert = standing.get(row.key); + const alert = standing.get(sameReminder(row.uid, row.fire_time)); if (!alert) { db.run("UPDATE reminder SET status = 'dismissed', updated_at = ? WHERE key = ?", [now, row.key]); continue; } - db.run("UPDATE reminder SET status = 'summarised', batch = ?, alert = ?, updated_at = ? WHERE key = ?", [batch, JSON.stringify(alert), now, row.key]); + db.run("UPDATE reminder SET status = 'summarised', batch = ?, lease_until = ?, alert = ?, updated_at = ? WHERE key = ?", [batch, now + LEASE_MS, JSON.stringify(alert), now, row.key]); alerts.push(alert); } return alerts.length > 0 ? { batch, alerts } : null; @@ -194,7 +210,17 @@ export function takeMissed(db: StorageAdapter, now: number, params: { timeZone: /** The summary couldn't be shown after all (paused, quiet hours): it stays due. */ export function releaseMissed(db: StorageAdapter, now: number, batch: number): void { - db.run("UPDATE reminder SET status = 'missed', batch = NULL, updated_at = ? WHERE status = 'summarised' AND batch = ?", [now, batch]); + db.run("UPDATE reminder SET status = 'missed', batch = NULL, lease_until = NULL, updated_at = ? WHERE status = 'summarised' AND batch = ?", [now, batch]); +} + +/** Still on screen here. */ +export function renewMissed(db: StorageAdapter, now: number, batch: number): void { + db.run("UPDATE reminder SET lease_until = ?, updated_at = ? WHERE status = 'summarised' AND batch = ? AND lease_until IS NOT NULL", [now + LEASE_MS, now, batch]); +} + +/** A native notification has it now, and the OS keeps that for as long as it wants. */ +export function settleMissed(db: StorageAdapter, now: number, batch: number): void { + db.run("UPDATE reminder SET lease_until = NULL, updated_at = ? WHERE status = 'summarised' AND batch = ?", [now, batch]); } /** What a summary said, for a click on it, possibly after a restart. */ @@ -213,12 +239,12 @@ export function holdFired(db: StorageAdapter, now: number, keys: readonly string export function snooze(db: StorageAdapter, now: number, key: string, minutes: number): boolean { if (!Number.isFinite(minutes) || minutes <= 0 || minutes > LONGEST_SNOOZE_MIN) return false; const until = now + minutes * 60_000; - return db.run("UPDATE reminder SET status = 'snoozed', snooze_until = ?, batch = NULL, updated_at = ? WHERE key = ?", [until, now, key]).changes > 0; + return db.run("UPDATE reminder SET status = 'snoozed', snooze_until = ?, batch = NULL, lease_until = NULL, updated_at = ? WHERE key = ?", [until, now, key]).changes > 0; } export function dismiss(db: StorageAdapter, now: number, keys: readonly string[]): void { db.transaction(() => { - for (const key of keys) db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, updated_at = ? WHERE key = ?", [now, key]); + for (const key of keys) db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, lease_until = NULL, updated_at = ? WHERE key = ?", [now, key]); }); } diff --git a/lib/store/reminders/schema.ts b/lib/store/reminders/schema.ts index 445e368e1..213eec18a 100644 --- a/lib/store/reminders/schema.ts +++ b/lib/store/reminders/schema.ts @@ -7,14 +7,18 @@ export const REMINDER_MIGRATIONS: readonly Migration[] = [ sql: ` CREATE TABLE reminder ( key TEXT PRIMARY KEY, + uid TEXT NOT NULL, status TEXT NOT NULL, fire_time INTEGER NOT NULL, snooze_until INTEGER, batch INTEGER, + -- A summary someone took: unless they say it's shown by then, it comes back. + lease_until INTEGER, alert TEXT NOT NULL, updated_at INTEGER NOT NULL ) WITHOUT ROWID; CREATE INDEX reminder_status ON reminder (status, snooze_until); + CREATE INDEX reminder_fire ON reminder (fire_time); `, }, ]; diff --git a/locales/ar/common.json b/locales/ar/common.json index 5b8dc9018..f28f51d1f 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "التذكيرات الفائتة", "missed_dialog_description": "حان موعد هذه التذكيرات بينما كان التطبيق مغلقًا أو الكمبيوتر في وضع السكون أو الإشعارات متوقفة مؤقتًا.", "open_calendar": "فتح التقويم", - "done": "تم" + "done": "تم", + "join_host": "انضمام ({host})", + "join_named": "انضمام: {title}" }, "desktop_policy": { "server_refused": "لا تسمح مؤسستك بهذا الخادم على هذا الجهاز.", diff --git a/locales/ca/common.json b/locales/ca/common.json index bb869aa7a..32dbea6b2 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Recordatoris perduts", "missed_dialog_description": "Van vèncer mentre l'aplicació estava tancada, l'ordinador en repòs o les notificacions en pausa.", "open_calendar": "Obre el calendari", - "done": "Fet" + "done": "Fet", + "join_host": "Uneix-te ({host})", + "join_named": "Uneix-te: {title}" }, "desktop_policy": { "server_refused": "La teva organització no permet aquest servidor en aquest dispositiu.", diff --git a/locales/cs/common.json b/locales/cs/common.json index bd1e23b46..9537f8cee 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Zmeškaná připomenutí", "missed_dialog_description": "Nastala, když byla aplikace zavřená, počítač v režimu spánku nebo oznámení pozastavená.", "open_calendar": "Otevřít kalendář", - "done": "Hotovo" + "done": "Hotovo", + "join_host": "Připojit se ({host})", + "join_named": "Připojit se: {title}" }, "desktop_policy": { "server_refused": "Vaše organizace tento server na tomto zařízení nepovoluje.", diff --git a/locales/da/common.json b/locales/da/common.json index f87fa10f6..61e4443be 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Oversete påmindelser", "missed_dialog_description": "De forfaldt, mens appen var lukket, computeren sov, eller notifikationer var sat på pause.", "open_calendar": "Åbn kalender", - "done": "Færdig" + "done": "Færdig", + "join_host": "Deltag ({host})", + "join_named": "Deltag: {title}" }, "desktop_policy": { "server_refused": "Din organisation tillader ikke denne server på denne enhed.", diff --git a/locales/de/common.json b/locales/de/common.json index 28fd528df..aee731846 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Verpasste Erinnerungen", "missed_dialog_description": "Diese wurden fällig, während die App geschlossen war, der Computer im Ruhezustand war oder Benachrichtigungen pausiert waren.", "open_calendar": "Kalender öffnen", - "done": "Fertig" + "done": "Fertig", + "join_host": "Teilnehmen ({host})", + "join_named": "Teilnehmen: {title}" }, "desktop_policy": { "server_refused": "Ihre Organisation erlaubt diesen Server auf diesem Gerät nicht.", diff --git a/locales/en/common.json b/locales/en/common.json index 6ecf5f81d..0103b768f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Reminders you missed", "missed_dialog_description": "These came due while the app was closed, the computer was asleep or notifications were paused.", "open_calendar": "Open calendar", - "done": "Done" + "done": "Done", + "join_host": "Join ({host})", + "join_named": "Join: {title}" }, "desktop_policy": { "server_refused": "Your organisation doesn't allow this server on this device.", diff --git a/locales/es/common.json b/locales/es/common.json index 6b659b2e0..99bba7a53 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Recordatorios perdidos", "missed_dialog_description": "Vencieron mientras la aplicación estaba cerrada, el ordenador en reposo o las notificaciones en pausa.", "open_calendar": "Abrir calendario", - "done": "Listo" + "done": "Listo", + "join_host": "Unirse ({host})", + "join_named": "Unirse: {title}" }, "desktop_policy": { "server_refused": "Tu organización no permite este servidor en este dispositivo.", diff --git a/locales/et/common.json b/locales/et/common.json index 5a4ff2f25..afb72f556 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Kasutamata meeldetuletused", "missed_dialog_description": "Need saabusid, kui rakendus oli suletud, arvuti puhkas või teavitused olid peatatud.", "open_calendar": "Ava kalender", - "done": "Valmis" + "done": "Valmis", + "join_host": "Liitu ({host})", + "join_named": "Liitu: {title}" }, "desktop_policy": { "server_refused": "Sinu organisatsioon ei luba seda serverit selles seadmes.", diff --git a/locales/fa/common.json b/locales/fa/common.json index d667dcbba..b02d5f8cf 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "یادآوری‌های از دست‌رفته", "missed_dialog_description": "این یادآوری‌ها زمانی رسیدند که برنامه بسته بود، رایانه در خواب بود یا اعلان‌ها متوقف بودند.", "open_calendar": "باز کردن تقویم", - "done": "تمام" + "done": "تمام", + "join_host": "پیوستن ({host})", + "join_named": "پیوستن: {title}" }, "desktop_policy": { "server_refused": "سازمان شما این سرور را در این دستگاه مجاز نمی‌داند.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 93f7b00d1..18183583c 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Rappels manqués", "missed_dialog_description": "Ils sont arrivés à échéance alors que l'application était fermée, l'ordinateur en veille ou les notifications en pause.", "open_calendar": "Ouvrir le calendrier", - "done": "Terminé" + "done": "Terminé", + "join_host": "Rejoindre ({host})", + "join_named": "Rejoindre: {title}" }, "desktop_policy": { "server_refused": "Votre organisation n'autorise pas ce serveur sur cet appareil.", diff --git a/locales/he/common.json b/locales/he/common.json index 0b542235c..74c7582e5 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6667,7 +6667,9 @@ "missed_dialog_title": "תזכורות שהוחמצו", "missed_dialog_description": "הן הגיעו כשהאפליקציה הייתה סגורה, המחשב היה במצב שינה או שההתראות הושהו.", "open_calendar": "פתיחת היומן", - "done": "סיום" + "done": "סיום", + "join_host": "הצטרפות ({host})", + "join_named": "הצטרפות: {title}" }, "desktop_policy": { "server_refused": "הארגון שלך לא מאפשר את השרת הזה במכשיר הזה.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 6044ed4c2..492932528 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Elmulasztott emlékeztetők", "missed_dialog_description": "Akkor jártak le, amikor az alkalmazás be volt zárva, a számítógép alvó állapotban volt, vagy az értesítések szüneteltek.", "open_calendar": "Naptár megnyitása", - "done": "Kész" + "done": "Kész", + "join_host": "Csatlakozás ({host})", + "join_named": "Csatlakozás: {title}" }, "desktop_policy": { "server_refused": "A szervezete ezen az eszközön nem engedélyezi ezt a kiszolgálót.", diff --git a/locales/it/common.json b/locales/it/common.json index a5813f6d6..6bf1d0ef1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Promemoria persi", "missed_dialog_description": "Sono scattati mentre l'app era chiusa, il computer in sospensione o le notifiche in pausa.", "open_calendar": "Apri calendario", - "done": "Fatto" + "done": "Fatto", + "join_host": "Partecipa ({host})", + "join_named": "Partecipa: {title}" }, "desktop_policy": { "server_refused": "La tua organizzazione non consente questo server su questo dispositivo.", diff --git a/locales/ja/common.json b/locales/ja/common.json index aade84482..a964c980c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "見逃したリマインダー", "missed_dialog_description": "アプリが閉じていた間、コンピューターがスリープしていた間、または通知を一時停止していた間に期限が来たものです。", "open_calendar": "カレンダーを開く", - "done": "完了" + "done": "完了", + "join_host": "参加 ({host})", + "join_named": "参加: {title}" }, "desktop_policy": { "server_refused": "組織はこのデバイスでこのサーバーを許可していません。", diff --git a/locales/ko/common.json b/locales/ko/common.json index e6001ba63..e1947740a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "놓친 알림", "missed_dialog_description": "앱이 닫혀 있거나 컴퓨터가 절전 상태이거나 알림이 일시 중지된 동안 시간이 된 알림입니다.", "open_calendar": "캘린더 열기", - "done": "완료" + "done": "완료", + "join_host": "참가 ({host})", + "join_named": "참가: {title}" }, "desktop_policy": { "server_refused": "조직에서 이 기기의 이 서버 사용을 허용하지 않습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index f8556eb48..ffb937c72 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Nokavētie atgādinājumi", "missed_dialog_description": "Tie pienāca, kamēr lietotne bija aizvērta, dators bija miega režīmā vai paziņojumi bija apturēti.", "open_calendar": "Atvērt kalendāru", - "done": "Gatavs" + "done": "Gatavs", + "join_host": "Pievienoties ({host})", + "join_named": "Pievienoties: {title}" }, "desktop_policy": { "server_refused": "Jūsu organizācija šajā ierīcē neatļauj šo serveri.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 3b7d53f53..2d448e385 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Алдсан сануулгууд", "missed_dialog_description": "Апп хаалттай, компьютер унтсан эсвэл мэдэгдэл түр зогссон үед хугацаа нь болсон.", "open_calendar": "Хуанли нээх", - "done": "Болсон" + "done": "Болсон", + "join_host": "Нэгдэх ({host})", + "join_named": "Нэгдэх: {title}" }, "desktop_policy": { "server_refused": "Танай байгууллага энэ төхөөрөмж дээр энэ серверийг зөвшөөрөхгүй байна.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 56e3864df..d3c55d894 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Tapte påminnelser", "missed_dialog_description": "De forfalt mens appen var lukket, datamaskinen sov eller varsler var satt på pause.", "open_calendar": "Åpne kalender", - "done": "Ferdig" + "done": "Ferdig", + "join_host": "Bli med ({host})", + "join_named": "Bli med: {title}" }, "desktop_policy": { "server_refused": "Organisasjonen din tillater ikke denne serveren på denne enheten.", diff --git a/locales/nl/common.json b/locales/nl/common.json index d1fc0da7a..219cc764f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Gemiste herinneringen", "missed_dialog_description": "Deze kwamen op terwijl de app gesloten was, de computer sliep of meldingen gepauzeerd waren.", "open_calendar": "Agenda openen", - "done": "Klaar" + "done": "Klaar", + "join_host": "Deelnemen ({host})", + "join_named": "Deelnemen: {title}" }, "desktop_policy": { "server_refused": "Je organisatie staat deze server niet toe op dit apparaat.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 8b8e15de8..b98857072 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Pominięte przypomnienia", "missed_dialog_description": "Pojawiły się, gdy aplikacja była zamknięta, komputer był uśpiony lub powiadomienia były wstrzymane.", "open_calendar": "Otwórz kalendarz", - "done": "Gotowe" + "done": "Gotowe", + "join_host": "Dołącz ({host})", + "join_named": "Dołącz: {title}" }, "desktop_policy": { "server_refused": "Twoja organizacja nie zezwala na ten serwer na tym urządzeniu.", diff --git a/locales/pt/common.json b/locales/pt/common.json index aa285479b..a164f9ad7 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Lembretes perdidos", "missed_dialog_description": "Venceram enquanto a aplicação estava fechada, o computador em suspensão ou as notificações em pausa.", "open_calendar": "Abrir calendário", - "done": "Concluído" + "done": "Concluído", + "join_host": "Entrar ({host})", + "join_named": "Entrar: {title}" }, "desktop_policy": { "server_refused": "A sua organização não permite este servidor neste dispositivo.", diff --git a/locales/ro/common.json b/locales/ro/common.json index a358376b9..7c2648f03 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Mementouri ratate", "missed_dialog_description": "Au venit la scadență cât timp aplicația era închisă, computerul în repaus sau notificările în pauză.", "open_calendar": "Deschide calendarul", - "done": "Gata" + "done": "Gata", + "join_host": "Participă ({host})", + "join_named": "Participă: {title}" }, "desktop_policy": { "server_refused": "Organizația ta nu permite acest server pe acest dispozitiv.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 01e71652a..ef3245cbc 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Пропущенные напоминания", "missed_dialog_description": "Они сработали, пока приложение было закрыто, компьютер спал или уведомления были приостановлены.", "open_calendar": "Открыть календарь", - "done": "Готово" + "done": "Готово", + "join_host": "Подключиться ({host})", + "join_named": "Подключиться: {title}" }, "desktop_policy": { "server_refused": "Ваша организация не разрешает этот сервер на этом устройстве.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 252f45e39..3779fde3e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Zmeškané pripomienky", "missed_dialog_description": "Nastali, keď bola aplikácia zatvorená, počítač v režime spánku alebo oznámenia pozastavené.", "open_calendar": "Otvoriť kalendár", - "done": "Hotovo" + "done": "Hotovo", + "join_host": "Pripojiť sa ({host})", + "join_named": "Pripojiť sa: {title}" }, "desktop_policy": { "server_refused": "Vaša organizácia tento server na tomto zariadení nepovoľuje.", diff --git a/locales/th/common.json b/locales/th/common.json index 11bb888b8..a43c5ecef 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6674,7 +6674,9 @@ "missed_dialog_title": "การแจ้งเตือนที่พลาด", "missed_dialog_description": "ถึงเวลาแจ้งเตือนขณะที่แอปปิดอยู่ คอมพิวเตอร์อยู่ในโหมดพัก หรือหยุดการแจ้งเตือนชั่วคราว", "open_calendar": "เปิดปฏิทิน", - "done": "เสร็จ" + "done": "เสร็จ", + "join_host": "เข้าร่วม ({host})", + "join_named": "เข้าร่วม: {title}" }, "desktop_policy": { "server_refused": "องค์กรของคุณไม่อนุญาตให้ใช้เซิร์ฟเวอร์นี้บนอุปกรณ์นี้", diff --git a/locales/tr/common.json b/locales/tr/common.json index 0bacdb6e7..83d8aaee0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Kaçırılan anımsatıcılar", "missed_dialog_description": "Uygulama kapalıyken, bilgisayar uykudayken veya bildirimler duraklatılmışken zamanı geldi.", "open_calendar": "Takvimi aç", - "done": "Bitti" + "done": "Bitti", + "join_host": "Katıl ({host})", + "join_named": "Katıl: {title}" }, "desktop_policy": { "server_refused": "Kuruluşunuz bu cihazda bu sunucuya izin vermiyor.", diff --git a/locales/uk/common.json b/locales/uk/common.json index be241ff05..4a05682d6 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "Пропущені нагадування", "missed_dialog_description": "Вони спрацювали, коли застосунок було закрито, комп'ютер спав або сповіщення були призупинені.", "open_calendar": "Відкрити календар", - "done": "Готово" + "done": "Готово", + "join_host": "Приєднатися ({host})", + "join_named": "Приєднатися: {title}" }, "desktop_policy": { "server_refused": "Ваша організація не дозволяє цей сервер на цьому пристрої.", diff --git a/locales/vi/common.json b/locales/vi/common.json index a52ffea04..ce36add4e 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6678,7 +6678,9 @@ "missed_dialog_title": "Lời nhắc đã bỏ lỡ", "missed_dialog_description": "Những lời nhắc này đến hạn khi ứng dụng đóng, máy tính ngủ hoặc thông báo bị tạm dừng.", "open_calendar": "Mở lịch", - "done": "Xong" + "done": "Xong", + "join_host": "Tham gia ({host})", + "join_named": "Tham gia: {title}" }, "desktop_policy": { "server_refused": "Tổ chức của bạn không cho phép máy chủ này trên thiết bị này.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index fdb148db0..029f4608b 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "錯過的提醒", "missed_dialog_description": "這些提醒在應用程式關閉、電腦睡眠或通知暫停時到期。", "open_calendar": "開啟日曆", - "done": "完成" + "done": "完成", + "join_host": "加入 ({host})", + "join_named": "加入: {title}" }, "desktop_policy": { "server_refused": "您的組織不允許在此裝置上使用此伺服器。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0e53fb826..49f53a5a6 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6666,7 +6666,9 @@ "missed_dialog_title": "错过的提醒", "missed_dialog_description": "这些提醒在应用关闭、电脑睡眠或通知暂停时到期。", "open_calendar": "打开日历", - "done": "完成" + "done": "完成", + "join_host": "加入 ({host})", + "join_named": "加入: {title}" }, "desktop_policy": { "server_refused": "您的组织不允许在此设备上使用此服务器。", diff --git a/public/sw.js b/public/sw.js index 581108f36..e72d7163e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1096,7 +1096,10 @@ function getReusableClientScore(state) { function buildClickUrl(data, reply) { if (!data) return `${BASE_PATH}/`; - if (data.kind === "calendar-reminder") return `${BASE_PATH}/calendar`; + if (data.kind === "calendar-reminder") { + // The page's address, with its language. Only ever a path on this origin. + return typeof data.url === "string" && /^\/(?!\/)/.test(data.url) ? data.url : `${BASE_PATH}/calendar`; + } const params = new URLSearchParams(); if (Number.isInteger(data.slot)) params.set("slot", String(data.slot)); if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId); From 436703b0f78a26b236c5551465c06b25b9aef013 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:40:54 +0100 Subject: [PATCH 29/45] fix: keep mail encrypted in the browser's private files during an export Firefox and Safari have no save picker, so an export wrote a plain file into the origin private file system for as long as it ran, and a crash left a whole mailbox readable there until the next start. The check that nothing readable sits in OPFS would find it. The file is now a spool: each piece is sealed with AES-GCM under a key that exists only in the tab's memory. When the export is done it opens into a Blob the browser holds, and the spool goes at once. The test that the private file stays while its download reads it changes to say the same of the Blob: the file no longer outlives the export, so what a download depends on is what gets released after the grace period. --- .../mail-transfer/mail-export-dialog.tsx | 21 +-- lib/mail-export/__tests__/sinks.test.ts | 163 ++++++++++++++++-- lib/mail-export/sinks.ts | 124 +++++++++++-- 3 files changed, 257 insertions(+), 51 deletions(-) diff --git a/components/mail-transfer/mail-export-dialog.tsx b/components/mail-transfer/mail-export-dialog.tsx index c3e7b6b99..9c09d14b7 100644 --- a/components/mail-transfer/mail-export-dialog.tsx +++ b/components/mail-transfer/mail-export-dialog.tsx @@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { isJobActive, useMailTransferStore, type ExportRequest } from "@/stores/mail-transfer-store"; import type { ExportFormat, ExportProgress } from "@/lib/mail-export/engine"; import { safeName } from "@/lib/mail-export/engine"; -import { canPickSaveFile, canWritePrivateFile, memorySink, pickSaveFile, privateFileSink, sweepPrivateExports, type ExportSink } from "@/lib/mail-export/sinks"; +import { canPickSaveFile, canWritePrivateFile, openExportSink } from "@/lib/mail-export/sinks"; import { formatFileSize } from "@/lib/utils"; import { formatClock, TransferBar, useFocusOnStage } from "./transfer-progress"; import { startExport } from "./start-export"; @@ -19,7 +19,7 @@ function today(): string { } /** Where the file will go in this browser, said before the export starts. */ -function saveMode(): "picker" | "download" | "memory" { +export function saveMode(): "picker" | "download" | "memory" { if (canPickSaveFile()) return "picker"; return canWritePrivateFile() ? "download" : "memory"; } @@ -57,21 +57,10 @@ export function MailExportDialog({ request }: { request: ExportRequest }) { const name = `${base}.${zip ? "zip" : "mbox"}`; const mime = zip ? "application/zip" : "application/mbox"; setStarting(true); - let sink: ExportSink | null = null; try { // The save picker must open straight from the click. - if (mode === "picker") { - try { - sink = await pickSaveFile(name, mime, zip ? ".zip" : ".mbox"); - } catch (error) { - if ((error as { name?: string } | null)?.name === "AbortError") return; - sink = null; - } - } - if (!sink) { - await sweepPrivateExports(); - sink = (await privateFileSink(name, mime)) ?? memorySink(name, mime); - } + const sink = await openExportSink(name, mime, zip ? ".zip" : ".mbox"); + if (!sink) return; startExport({ format, accountId: mailbox?.accountId || client.getAccountId(), @@ -165,7 +154,7 @@ export function MailExportDialog({ request }: { request: ExportRequest }) { type T = ReturnType; -function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) { +export function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) { const running = p.phase === "running" || p.phase === "paused"; // The live region says only what changes the user's plans (paused, done); // the count beside it would otherwise be read out several times a second. diff --git a/lib/mail-export/__tests__/sinks.test.ts b/lib/mail-export/__tests__/sinks.test.ts index 915cb4932..f2e7f1551 100644 --- a/lib/mail-export/__tests__/sinks.test.ts +++ b/lib/mail-export/__tests__/sinks.test.ts @@ -1,15 +1,18 @@ +// @vitest-environment node import { describe, it, expect, vi, afterEach } from 'vitest'; -import { canWritePrivateFile, downloadGraceMs, privateFileSink, sweepPrivateExports } from '@/lib/mail-export/sinks'; +import { canWritePrivateFile, downloadGraceMs, guardedSink, openExportSink, privateFileSink, sweepPrivateExports, ExportForbiddenError, type ExportFile, type ExportSink } from '@/lib/mail-export/sinks'; /** A tiny origin private file system: one directory of files. */ function fakeOpfs() { const files = new Map(); + /** Everything ever written to this file system, deleted or not. */ + const written: Uint8Array[] = []; const dir = { getFileHandle: async (name: string) => { if (!files.has(name)) files.set(name, []); return { createWritable: async () => ({ - write: async (data: Uint8Array) => { files.get(name)!.push(data); }, + write: async (data: Uint8Array) => { files.get(name)!.push(data); written.push(data); }, close: async () => {}, abort: async () => {}, }), @@ -29,7 +32,7 @@ function fakeOpfs() { removeEntry: vi.fn(async () => { files.clear(); }), }; let created = false; - return { files, root }; + return { files, written, root }; } /** Web Locks where `held` names belong to another tab. */ @@ -66,30 +69,158 @@ describe('sweepPrivateExports', () => { }); describe('privateFileSink', () => { - it('keeps the file while its download may still read it, and deletes it after', async () => { - vi.useFakeTimers(); + const MAIL = new TextEncoder().encode('Subject: Very secret mail\r\n\r\nThe plain body\r\n'); + + /** A sink on a fake file system; `handed` is what would be downloaded. */ + function setUp() { const opfs = fakeOpfs(); + const handed: Blob[] = []; + const revoke = vi.fn(); vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); - vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() })); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: (blob: Blob) => { handed.push(blob); return 'blob:x'; }, revokeObjectURL: revoke })); + return { opfs, handed, revoke }; + } + + const asText = (parts: Uint8Array[]) => Buffer.concat(parts).toString('latin1'); + + it('never writes readable mail to the private files, and hands out the exact bytes', async () => { + const { opfs, handed } = setUp(); const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; - await sink.write(new Uint8Array(10)); + // More than one piece, so the pieces have to come back in order. + const big = new Uint8Array(1024 * 1024 + 7).map((_, i) => i % 251); + await sink.write(MAIL); + await sink.write(big); + await sink.write(big); const file = await sink.close(); - const gone = file.discard(); - await vi.advanceTimersByTimeAsync(30_000); + expect(opfs.written.length).toBeGreaterThanOrEqual(2); + expect(asText(opfs.written)).not.toContain('secret'); + expect(asText(opfs.written)).not.toContain('plain body'); + expect(handed).toHaveLength(1); + expect(handed[0].type).toBe('application/mbox'); + // Not toEqual: it walks 2 MB element by element. + expect(Buffer.from(await handed[0].arrayBuffer()).equals(Buffer.concat([MAIL, big, big]))).toBe(true); + expect(file.size).toBe(MAIL.length + 2 * big.length); + }); + + it('seals the same mail differently each time', async () => { + const { opfs } = setUp(); + for (let i = 0; i < 2; i += 1) { + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + await sink.close(); + } + expect(opfs.written).toHaveLength(2); + expect(asText([opfs.written[0]])).not.toBe(asText([opfs.written[1]])); + }); + + it('deletes the spool the moment the export is done, before any download is over', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); expect(opfs.files.size).toBe(1); - await vi.advanceTimersByTimeAsync(downloadGraceMs(10)); - await gone; + await sink.close(); expect(opfs.files.size).toBe(0); }); - it('deletes at once on sign-out', async () => { + it('deletes the spool when the export is cancelled', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + await sink.abort(); + expect(opfs.files.size).toBe(0); + }); + + it('keeps the Blob while its download may still read it, and frees it after', async () => { + const { revoke } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + const file: ExportFile = await sink.close(); + vi.useFakeTimers(); + const gone = file.discard(); + await vi.advanceTimersByTimeAsync(30_000); + expect(revoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(downloadGraceMs(MAIL.length)); + await gone; + expect(revoke).toHaveBeenCalledWith('blob:x'); + }); + + it('frees the Blob at once on sign-out', async () => { + const { revoke } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + await (await sink.close()).discard(true); + expect(revoke).toHaveBeenCalledWith('blob:x'); + }); + + it('leaves nothing behind when the Blob can\'t be made', async () => { + const { opfs, handed } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + vi.stubGlobal('Response', class { blob() { return Promise.reject(new Error('out of memory')); } }); + await expect(sink.close()).rejects.toThrow('out of memory'); + await sink.abort(); + expect(handed).toEqual([]); + expect(opfs.files.size).toBe(0); + }); + + it('a crash leaves only sealed bytes, which the sweep at the next start deletes', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL.slice()); + // 1 MB forces a piece out; a crash here never reaches close or abort. + await sink.write(new Uint8Array(1 << 20)); + expect(asText(opfs.written)).not.toContain('secret'); + expect(opfs.files.size).toBe(1); + // The tab that held the file is gone: its lock is free again. + await sweepPrivateExports(); + expect(opfs.files.size).toBe(0); + }); +}); + +describe('guardedSink', () => { + function fakeSink(): ExportSink & { write: ReturnType; close: ReturnType } { + return { + kind: 'picker', + write: vi.fn(async () => {}), + close: vi.fn(async () => ({ name: 'x', size: 0, discard: async () => {} })), + abort: vi.fn(async () => {}), + }; + } + + it('passes writes through while allowed', async () => { + const inner = fakeSink(); + const sink = guardedSink(inner, () => true); + await sink.write(new Uint8Array(1)); + await sink.close(); + expect(inner.write).toHaveBeenCalledTimes(1); + expect(inner.close).toHaveBeenCalledTimes(1); + }); + + it('stops a write and a close the moment the policy says no', async () => { + const inner = fakeSink(); + const sink = guardedSink(inner, () => false); + await expect(sink.write(new Uint8Array(1))).rejects.toBeInstanceOf(ExportForbiddenError); + await expect(sink.close()).rejects.toBeInstanceOf(ExportForbiddenError); + expect(inner.write).not.toHaveBeenCalled(); + expect(inner.close).not.toHaveBeenCalled(); + }); +}); + +describe('openExportSink', () => { + it('uses the picker where there is one, and nothing else when the user closes it', async () => { const opfs = fakeOpfs(); vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); - vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() })); - const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; - await sink.write(new Uint8Array(10)); - await (await sink.close()).discard(true); + vi.stubGlobal('window', { showSaveFilePicker: async () => { throw new DOMException('closed', 'AbortError'); } }); + expect(await openExportSink('a.mbox', 'application/mbox', '.mbox')).toBeNull(); expect(opfs.files.size).toBe(0); + vi.stubGlobal('window', { showSaveFilePicker: async () => ({ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }) }) }); + expect((await openExportSink('a.mbox', 'application/mbox', '.mbox'))?.kind).toBe('picker'); + }); + + it('goes to the spool where there is no picker', async () => { + const opfs = fakeOpfs(); + vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); + expect((await openExportSink('a.mbox', 'application/mbox', '.mbox'))?.kind).toBe('private'); }); }); diff --git a/lib/mail-export/sinks.ts b/lib/mail-export/sinks.ts index 30908697d..ac849578f 100644 --- a/lib/mail-export/sinks.ts +++ b/lib/mail-export/sinks.ts @@ -7,12 +7,18 @@ import { useMailTransferStore } from "@/stores/mail-transfer-store"; * * 1. File System Access (Chrome, Edge): the user picks the file first and the * export writes straight into it. - * 2. The origin private file system (Firefox, Safari): written to a private - * file on disk, then downloaded. The file is deleted a while after the - * dialog closes (so the download can finish), at once on sign-out, and by - * a sweep when the app starts, someone signs in or the next export starts. - * Each file is held under a Web Lock while its tab uses it, so the sweep - * of another tab leaves it alone. + * 2. The origin private file system (Firefox, Safari): an encrypted spool, so + * no readable mail is ever in those files. Each 1 MB piece is sealed with + * AES-GCM under a key made for this export, which can't be exported and is + * only ever in this tab's memory: a crash leaves bytes nobody can read. + * When the export is done the pieces are opened into a Blob the browser + * holds (Firefox and Chromium keep a large one in their own temporary + * storage), the spool is deleted at once, and the Blob is downloaded. The + * Blob goes a while after the dialog closes, so the download can finish, or + * at once on sign-out or a wipe. A sweep deletes what a crash left when the + * app starts, someone signs in or the next export starts. Each spool is + * held under a Web Lock while its tab uses it, so the sweep of another tab + * leaves it alone. * 3. Memory, for small exports only, where neither exists. * * Writes are gathered into 1 MB pieces: a message arrives in many small parts. @@ -41,11 +47,19 @@ export class ExportTooLargeError extends Error { } } +/** The device policy "No export" came on while the export ran. */ +export class ExportForbiddenError extends Error { + constructor() { + super("The organisation doesn't allow exporting mail on this device"); + this.name = "ExportForbiddenError"; + } +} + /** The most a memory-only export may hold. */ export const MEMORY_EXPORT_LIMIT = 200 * 1024 * 1024; const PRIVATE_DIR = "bulwark-exports"; const LOCK_PREFIX = "bulwark-export-file:"; -/** How long a closed export's private file stays for its download: a minute, plus a second per 50 MB. */ +/** How long a closed export's Blob stays for its download: a minute, plus a second per 50 MB. */ export function downloadGraceMs(size: number): number { return 60_000 + Math.ceil(size / 50_000_000) * 1000; } @@ -139,10 +153,37 @@ async function privateDir(): Promise { } } -/** A private file on disk, downloaded when done; null where the browser can't write one from the page. */ +/** The IV of a piece: its number. A key seals one spool, so a number never repeats under it. */ +function pieceIv(piece: number): Uint8Array { + const iv = new Uint8Array(12); + new DataView(iv.buffer).setUint32(8, piece); + return iv; +} + +/** Reads the pieces back in order, one at a time, opened. A piece that was changed fails the read. */ +function openedPieces(file: Blob, sizes: readonly number[], key: CryptoKey): ReadableStream { + let piece = 0; + let at = 0; + return new ReadableStream({ + async pull(controller) { + if (piece === sizes.length) { + controller.close(); + return; + } + const size = sizes[piece]; + const sealed = await file.slice(at, at + size).arrayBuffer(); + controller.enqueue(new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: pieceIv(piece) }, key, sealed))); + piece += 1; + at += size; + }, + }); +} + +/** An encrypted spool on disk, downloaded when done; null where the browser can't write one from the page. */ export async function privateFileSink(name: string, mime: string): Promise { const dir = await privateDir(); - if (!dir) return null; + if (!dir || !globalThis.crypto?.subtle) return null; + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); const entry = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // Held until the file is gone, so no other tab's sweep deletes it meanwhile. const release = (await holdLock(`${LOCK_PREFIX}${entry}`)) ?? (() => {}); @@ -162,7 +203,12 @@ export async function privateFileSink(name: string, mime: string): Promise writable.write(chunk as BufferSource)); + const sizes: number[] = []; + const buf = buffered(async (chunk) => { + const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: pieceIv(sizes.length) }, key, chunk as BufferSource)); + await writable.write(sealed); + sizes.push(sealed.length); + }); const remove = async () => { await dir.removeEntry(entry).catch(() => {}); release(); @@ -173,18 +219,19 @@ export async function privateFileSink(name: string, mime: string): Promise { await buf.flush(); await writable.close(); - const file = await handle.getFile(); - const url = URL.createObjectURL(new Blob([file], { type: mime })); + // Through a Response so the browser may spill a large Blob to its own storage. + const opened = await new Response(openedPieces(await handle.getFile(), sizes, key)).blob(); + await remove(); + const url = URL.createObjectURL(new Blob([opened], { type: mime })); triggerDownload(url, name); return { name, - size: file.size, + size: opened.size, download: () => triggerDownload(url, name), discard: async (now = false) => { - // Deleting the file under a download that is still reading it would break the download. - if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(file.size))); + // Taking the Blob away from a download that is still reading it would break the download. + if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(opened.size))); URL.revokeObjectURL(url); - await remove(); }, }; }, @@ -192,6 +239,23 @@ export async function privateFileSink(name: string, mime: string): Promise { + if (canPickSaveFile()) { + try { + return await pickSaveFile(name, mime, extension); + } catch (error) { + if ((error as { name?: string } | null)?.name === "AbortError") return null; + } + } + await sweepPrivateExports(); + return (await privateFileSink(name, mime)) ?? memorySink(name, mime); +} + /** Everything in memory, then downloaded: only for small exports. */ export function memorySink(name: string, mime: string, limit = MEMORY_EXPORT_LIMIT): ExportSink { let parts: Uint8Array[] = []; @@ -223,9 +287,31 @@ export function memorySink(name: string, mime: string, limit = MEMORY_EXPORT_LIM } /** - * Deletes private export files no tab uses any more (a closed tab, a crash), - * so no copy of the mail stays in the browser profile. A file another tab - * holds is left alone; `force` deletes everything (everyone signed out). + * Stops an export the moment `allowed` says no, whichever sink it writes to: the + * save picker writes straight to the user's file, past the guard in saveBlobAs. + */ +export function guardedSink(sink: ExportSink, allowed: () => boolean): ExportSink { + const check = () => { + if (!allowed()) throw new ExportForbiddenError(); + }; + return { + kind: sink.kind, + write: async (chunk) => { + check(); + await sink.write(chunk); + }, + close: async () => { + check(); + return sink.close(); + }, + abort: () => sink.abort(), + }; +} + +/** + * Deletes private export files no tab uses any more (a closed tab, a crash). + * They're encrypted under a key that died with the tab, but one from an older + * version is plain, and they take up room. A file another tab holds is left alone; `force` deletes everything (everyone signed out). * Without Web Locks only `force` sweeps, since another tab can't be asked. */ export async function sweepPrivateExports({ force = false }: { force?: boolean } = {}): Promise { From 3cda05a1fa207e593643de5fa9223a5a2ae0e9f7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:40:57 +0100 Subject: [PATCH 30/45] feat: export mail kept on this device to mbox or EML zip, also offline --- .../__tests__/local-export.test.tsx | 463 ++++++++++++++++++ .../mail-transfer/local-export-dialog.tsx | 265 ++++++++++ .../mail-transfer/mail-transfer-host.tsx | 49 +- .../mail-transfer/start-local-export.ts | 104 ++++ components/offline/account-storage.tsx | 6 + integration/tests/24-do-export.spec.ts | 300 ++++++++++++ lib/desktop/taskbar-progress.ts | 13 + .../__tests__/local-store-export.test.ts | 261 ++++++++++ lib/mail-export/engine.ts | 41 +- lib/mail-export/local-store-client.ts | 126 +++++ lib/offline/wipe-registry.ts | 39 ++ lib/store/mail/attachments.ts | 11 + lib/store/mail/calls.ts | 4 +- locales/ar/common.json | 23 + locales/ca/common.json | 23 + locales/cs/common.json | 23 + locales/da/common.json | 23 + locales/de/common.json | 23 + locales/en/common.json | 23 + locales/es/common.json | 23 + locales/et/common.json | 23 + locales/fa/common.json | 23 + locales/fr/common.json | 23 + locales/he/common.json | 23 + locales/hu/common.json | 23 + locales/it/common.json | 23 + locales/ja/common.json | 23 + locales/ko/common.json | 23 + locales/lv/common.json | 23 + locales/mn/common.json | 23 + locales/nb/common.json | 23 + locales/nl/common.json | 23 + locales/pl/common.json | 23 + locales/pt/common.json | 23 + locales/ro/common.json | 23 + locales/ru/common.json | 23 + locales/sk/common.json | 23 + locales/th/common.json | 23 + locales/tr/common.json | 23 + locales/uk/common.json | 23 + locales/vi/common.json | 23 + locales/zh-TW/common.json | 23 + locales/zh/common.json | 23 + stores/mail-transfer-store.ts | 14 +- 44 files changed, 2370 insertions(+), 16 deletions(-) create mode 100644 components/mail-transfer/__tests__/local-export.test.tsx create mode 100644 components/mail-transfer/local-export-dialog.tsx create mode 100644 components/mail-transfer/start-local-export.ts create mode 100644 integration/tests/24-do-export.spec.ts create mode 100644 lib/desktop/taskbar-progress.ts create mode 100644 lib/mail-export/__tests__/local-store-export.test.ts create mode 100644 lib/mail-export/local-store-client.ts create mode 100644 lib/offline/wipe-registry.ts diff --git a/components/mail-transfer/__tests__/local-export.test.tsx b/components/mail-transfer/__tests__/local-export.test.tsx new file mode 100644 index 000000000..477270d85 --- /dev/null +++ b/components/mail-transfer/__tests__/local-export.test.tsx @@ -0,0 +1,463 @@ +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 type { ExportFile, ExportSink } from '@/lib/mail-export/sinks'; +import { useOfflineStore } from '@/lib/offline/state'; +import { runWipeCleaners } from '@/lib/offline/wipe-registry'; +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'; + +const FOLDERS = [ + { 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 } = {}) { + const area = vi.fn(async (_account: string, name: string, params?: { mailboxId?: string; blobId?: string }): Promise => { + if (name === 'mail.session') return { primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-a' } }; + if (name === 'mail.rawStatus') return 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.rawKept') return []; + return null; + }); + const answer = vi.fn(async (_account: string, calls: [string, Record, string][]) => { + const [name, args] = calls[0]; + if (name === 'Mailbox/get') return { methodResponses: [['Mailbox/get', { list: FOLDERS }, '0']] }; + if (name === 'Email/query') return { methodResponses: [['Email/query', { ids: (args.position as number) > 0 ? [] : ['m1'] }, '0']] }; + return { methodResponses: [['Email/get', { list: [{ id: 'm1', blobId: 'raw-m1', size: RAW.length, keywords: {}, receivedAt: '2026-10-01T00:00:00Z', subject: 'x' }] }, '0']] }; + }); + return { supported: () => true, setScope: vi.fn(async () => {}), area, answer }; +} + +function setDevices(devices: Partial) { + usePolicyStore.setState({ + loaded: true, source: 'server', partial: false, subject: { email: 'alice@example.org', groups: [] }, + policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } }, + } as never); +} + +function fakeSink(): ExportSink & { write: ReturnType; abort: ReturnType; close: ReturnType } { + const file: ExportFile = { name: 'a.mbox', size: 1, discard: vi.fn(async () => {}) }; + return { kind: 'picker', write: vi.fn(async () => {}), close: vi.fn(async () => file), abort: vi.fn(async () => {}) }; +} + +/** The one lib/device-policy.ts installs, for tests that put their own in for a moment. */ +const installedGuard: Parameters[0] = (kind, subject) => { + const devices = subject === undefined ? getStrictestDevices() : devicesForAccount(subject as string | null); + return kind === 'export' ? !devices.noExport : !devices.noDownloads; +}; + +const realClient = { getServerUrl: () => 'https://mail.example.org', getAccountId: () => 'jmap-a', getMaxConcurrentRequests: () => 4 }; + +function setOnline(online: boolean) { + useOfflineStore.setState({ browserOffline: !online, downServers: [] }); +} + +beforeEach(() => { + useMailTransferStore.setState({ request: null, visible: false, job: null }); + useAuthStore.setState({ + client: realClient as never, + activeAccountId: 'acc', + getClientForAccount: ((id: string) => (id === 'acc' ? realClient : undefined)) as never, + getAllConnectedClients: (() => new Map([['acc', realClient]])) as never, + } as never); + useAccountStore.setState({ accounts: [{ id: 'acc', email: 'alice@example.org', username: 'alice@example.org', serverUrl: 'https://mail.example.org' }] } as never); + useOfflineStore.setState({ + enabled: ['acc', 'other'], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], syncing: [], lastSyncedAt: {}, choices: { acc: { on: true } }, health: {}, + storageFull: {}, progress: {}, persist: 'granted', browserOffline: false, downServers: [], startedOffline: false, + usage: { acc: { bytes: 5000, parts: [] } }, + }); + setDevices({}); + setStoreBackend(fakeStore() as unknown as StoreBackend); +}); + +afterEach(() => { + cleanup(); + setStoreBackend(null); + delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + vi.unstubAllGlobals(); +}); + +async function openDialog() { + render(); + act(() => openLocalExport('acc')); + await screen.findByTestId('local-export-summary'); +} + +/** Runs one export to its end through the job. */ +async function finishedJob(sink = fakeSink()) { + expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: ['Inbox'] }], sink })).toBe(true); + await act(async () => { await useMailTransferStore.getState().job!.finished; }); + return sink; +} + +describe('before an export starts', () => { + it('says how many originals are here and how many come from the server when online', async () => { + await openDialog(); + expect(screen.getByTestId('local-export-kept').textContent).toBe('kept'); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); + expect(screen.queryByTestId('local-export-reason')).toBeNull(); + }); + + it('says offline how many can\'t be exported, and why', async () => { + setOnline(false); + await openDialog(); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_offline'); + expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_not_kept'); + }); + + it('gives attachments being off as the reason when they are', async () => { + setOnline(false); + useOfflineStore.setState({ choices: { acc: { on: true, attachments: false } } } as never); + await openDialog(); + expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_attachments_off'); + }); + + it('counts only the folders chosen, and offers none of the rest', async () => { + setOnline(false); + await openDialog(); + const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType; + expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', { jmap: 'jmap-a', mailboxId: 'inbox' }); + const boxes = screen.getAllByTestId('local-export-folder') as HTMLInputElement[]; + expect(boxes.map((box) => box.dataset.folder)).toEqual(['Inbox', 'Archive']); + fireEvent.click(boxes[0]); + // Only Archive now: all of its originals are here. + expect(screen.queryByTestId('local-export-missing')).toBeNull(); + const all = screen.getByTestId('local-export-all') as HTMLInputElement; + expect(all.indeterminate).toBe(true); + fireEvent.click(all); + expect(boxes.every((box) => box.checked)).toBe(true); + }); + + it('can\'t start with nothing chosen', async () => { + await openDialog(); + fireEvent.click(screen.getByTestId('local-export-all')); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('local-export-nothing')).toBeTruthy(); + }); + + it('opens the picker from the click, with a name for what was chosen', async () => { + const writable = { write: vi.fn(async () => {}), close: vi.fn(async () => {}), abort: vi.fn(async () => {}) }; + const picker = vi.fn(async () => ({ createWritable: async () => writable })); + (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker; + await openDialog(); + fireEvent.click(screen.getAllByTestId('local-export-folder')[1]); + fireEvent.click(screen.getByTestId('local-export-start')); + await waitFor(() => expect(picker).toHaveBeenCalled()); + expect(picker).toHaveBeenCalledWith(expect.objectContaining({ suggestedName: 'Inbox.mbox' })); + await waitFor(() => expect(useMailTransferStore.getState().job?.progress.phase).toBe('done')); + expect(writable.close).toHaveBeenCalled(); + }); +}); + +describe('the device policy "No export"', () => { + it('locks the action in settings, with the reason beside it', () => { + setDevices({ noExport: true }); + render(); + expect((screen.getByTestId('local-export-open') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('settings_label').closest('[data-search-label]')!.querySelector('[data-org-locked]')).toBeTruthy(); + }); + + it('leaves the action in settings open when it\'s off, and that opens the dialog', async () => { + render(); + fireEvent.click(screen.getByTestId('local-export-open')); + expect(useMailTransferStore.getState().request).toEqual({ kind: 'local-export', account: 'acc' }); + }); + + it('keeps the dialog from opening', () => { + setDevices({ noExport: true }); + act(() => openLocalExport('acc')); + expect(useMailTransferStore.getState().request).toBeNull(); + }); + + it('refuses to start, gives back the sink and reads nothing', () => { + const store = fakeStore(); + setStoreBackend(store as unknown as StoreBackend); + setDevices({ noExport: true }); + const sink = fakeSink(); + expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(false); + expect(sink.abort).toHaveBeenCalled(); + expect(store.answer).not.toHaveBeenCalled(); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('counts as on while the organisation\'s word isn\'t known, as at a first start offline', () => { + usePolicyStore.setState({ loaded: false, source: 'none' } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ loaded: true, source: 'fallback' } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ loaded: true, source: 'server', partial: true } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ loaded: true, source: 'cache', partial: false } as never); + expect(localExportAllowed('acc')).toBe(true); + }); + + it('stops a running export the moment it\'s switched on, and its file goes', async () => { + let release: () => void = () => {}; + const store = fakeStore(); + store.answer.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); })); + setStoreBackend(store as unknown as StoreBackend); + const sink = fakeSink(); + startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink }); + const job = useMailTransferStore.getState().job!; + expect(isJobActive(job)).toBe(true); + await waitFor(() => expect(store.answer).toHaveBeenCalled()); + act(() => setDevices({ noExport: true })); + expect(job.controller.signal.aborted).toBe(true); + release(); + await act(async () => { await job.finished; }); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled'); + expect(sink.abort).toHaveBeenCalled(); + expect(sink.close).not.toHaveBeenCalled(); + }); + + it('asks about the account the export is for, not the active one', async () => { + useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never); + 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 that stopped it', async () => { + const sink = fakeSink(); + render(); + act(() => openLocalExport('acc')); + let allowed = true; + setDownloadGuard(() => allowed); + try { + sink.write.mockImplementation(async () => { allowed = false; }); + await finishedJob(sink); + expect(screen.getByText('export_blocked')).toBeTruthy(); + } finally { + setDownloadGuard(installedGuard); + } + }); +}); + +describe('what an export couldn\'t write', () => { + const notKept = () => setStoreBackend(fakeStore({ rawBlob: () => ({ ok: false, reason: 'not_kept' }) }) as unknown as StoreBackend); + + it('counts it offline with the reason and no way to retry until online', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + expect((useMailTransferStore.getState().job?.progress as ExportProgress).notKept).toEqual({ not_kept: 1 }); + expect(screen.getByTestId('local-export-not-kept-missing').textContent).toBe('not_kept_not_kept'); + expect(screen.queryByTestId('local-export-not-kept-attachments')).toBeNull(); + expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_offline'); + expect((screen.getByTestId('local-export-retry') as HTMLButtonElement).disabled).toBe(true); + }); + + it('offers the retry once the connection is back, which goes back to the question with fresh counts', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + act(() => setOnline(true)); + expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_online'); + const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType; + area.mockClear(); + fireEvent.click(screen.getByTestId('local-export-retry')); + await screen.findByTestId('local-export-summary'); + expect(useMailTransferStore.getState().job).toBeNull(); + expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', expect.anything()); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); + }); + + it('offers no retry when everything was written', async () => { + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'); + expect(screen.queryByTestId('local-export-retry')).toBeNull(); + }); +}); + +describe('while it runs', () => { + it('shows the progress, and Cancel stops it and gives up its file', async () => { + const sink = fakeSink(); + let finish: () => void = () => {}; + const store = fakeStore(); + store.answer.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); })); + setStoreBackend(store as unknown as StoreBackend); + render(); + act(() => openLocalExport('acc')); + act(() => { startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink }); }); + expect(screen.getByRole('progressbar')).toBeTruthy(); + expect(screen.getByTestId('mail-export-status').getAttribute('role')).toBe('status'); + await waitFor(() => expect(store.answer).toHaveBeenCalled()); + const job = useMailTransferStore.getState().job!; + fireEvent.click(screen.getByTestId('local-export-cancel')); + expect(job.controller.signal.aborted).toBe(true); + finish(); + await act(async () => { await job.finished; }); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled'); + expect(sink.abort).toHaveBeenCalled(); + expect(sink.close).not.toHaveBeenCalled(); + expect(screen.getByText('cancelled_title')).toBeTruthy(); + }); +}); + +describe('an export is cleaned up', () => { + /** A job that doesn't end until it's aborted. */ + function runningJob(account = 'acc') { + const sink = fakeSink(); + const store = fakeStore(); + // Like a store read: under way until the job is stopped. + store.answer.mockImplementation(() => new Promise((_resolve, reject) => { + const signal = useMailTransferStore.getState().job!.controller.signal; + const stop = () => reject(new DOMException('stopped', 'AbortError')); + if (signal.aborted) stop(); + else signal.addEventListener('abort', stop); + })); + setStoreBackend(store as unknown as StoreBackend); + expect(startLocalExport({ account, jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true); + return { sink, job: useMailTransferStore.getState().job! }; + } + + function stubSweep() { + const removed: string[] = []; + const dir = { + keys: async function* () { yield 'old-spool'; }, + removeEntry: async (name: string) => { removed.push(name); }, + }; + const root = { + getDirectoryHandle: async () => dir, + removeEntry: vi.fn(async () => {}), + }; + vi.stubGlobal('navigator', { ...navigator, storage: { getDirectory: async () => root }, locks: { request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb({ name }) } }); + return { removed, root }; + } + + it('on a wipe of its account: stops the job and forgets it', async () => { + render(); + const { job } = runningJob(); + await act(async () => { await runWipeCleaners('acc'); }); + expect(job.controller.signal.aborted).toBe(true); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('a wipe of another account leaves the job alone', async () => { + render(); + const { job } = runningJob(); + await act(async () => { await runWipeCleaners('someone-else'); }); + expect(job.controller.signal.aborted).toBe(false); + expect(useMailTransferStore.getState().job).toBe(job); + }); + + it('on a wipe of everything: also every spool, whoever holds it', async () => { + render(); + const { job } = runningJob(); + const { root } = stubSweep(); + await act(async () => { await runWipeCleaners(null); }); + expect(job.controller.signal.aborted).toBe(true); + expect(root.removeEntry).toHaveBeenCalledWith('bulwark-exports', { recursive: true }); + }); + + it('on a wipe of one account: the spools nobody holds go too', async () => { + render(); + const { removed, root } = stubSweep(); + await act(async () => { await runWipeCleaners('acc'); }); + expect(removed).toEqual(['old-spool']); + expect(root.removeEntry).not.toHaveBeenCalled(); + }); + + it('on sign-out of its account: stops the job and forgets it', async () => { + render(); + const { job } = runningJob(); + await act(async () => { await runBeforeSignOut('acc'); }); + expect(job.controller.signal.aborted).toBe(true); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('frees a finished export\'s file at once on a wipe', async () => { + render(); + const sink = fakeSink(); + await finishedJob(sink); + const file = (useMailTransferStore.getState().job!.progress as ExportProgress).file!; + await act(async () => { await runWipeCleaners('acc'); }); + expect(file.discard).toHaveBeenCalledWith(true); + }); + + it('when the account stops keeping mail here: stops the job', () => { + const { job } = runningJob(); + act(() => useOfflineStore.setState({ enabled: ['other'] })); + expect(job.controller.signal.aborted).toBe(true); + }); + + it('at start-up: sweeps what a crash left', async () => { + const { removed } = stubSweep(); + render(); + await waitFor(() => expect(removed).toEqual(['old-spool'])); + }); +}); + +describe('the taskbar in the desktop app', () => { + it('shows a running export and takes it away at the end', async () => { + const setProgress = vi.fn(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } }; + render(); + await finishedJob(); + const calls = setProgress.mock.calls; + expect(calls[0]).toEqual([0, 'normal']); + expect(calls[calls.length - 1][0]).toBeNull(); + }); + + it('says when it\'s paused', async () => { + const setProgress = vi.fn(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } }; + render(); + const store = useMailTransferStore.getState(); + act(() => store.startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'paused', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } })); + expect(setProgress).toHaveBeenLastCalledWith(0.25, 'paused'); + }); + + it('asks nothing of a shell without it, or of a browser', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' }; + render(); + await finishedJob(); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'); + }); +}); diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx new file mode 100644 index 000000000..248fe5cf2 --- /dev/null +++ b/components/mail-transfer/local-export-dialog.tsx @@ -0,0 +1,265 @@ +"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 { folderTree, safeName, type ExportFolder, type ExportFormat } from "@/lib/mail-export/engine"; +import { openExportSink } 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 { useAccountStore } from "@/stores/account-store"; +import { isJobActive, useMailTransferStore, type LocalExportRequest } from "@/stores/mail-transfer-store"; +import { ExportProgressBody, saveMode } from "./mail-export-dialog"; +import { localExportAllowed, localExportReachable, localExportReader, primaryJmapAccount, startLocalExport } from "./start-local-export"; +import { useFocusOnStage } from "./transfer-progress"; + +interface FolderRow { + folder: ExportFolder; + total: number; + kept: number; +} + +type Loaded = { jmap: string; rows: FolderRow[] }; + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** What an account keeps, folder by folder, for the question before an export starts. */ +async function readFolders(account: string): Promise { + const jmap = await primaryJmapAccount(account); + if (!jmap) throw new Error("The account has no mail account in the store"); + const folders = folderTree(await localExportReader(account, jmap).getMailboxes()); + const rows: FolderRow[] = []; + for (const folder of folders) { + const status = await getStore().area<{ total: number; kept: number }>(account, "mail.rawStatus", { jmap, mailboxId: folder.mailboxId }); + rows.push({ folder, ...status }); + } + return { jmap, rows }; +} + +/** Settings > Backup & reset > Offline > Export kept mail: from the local store, also with no server. */ +export function LocalExportDialog({ request }: { request: LocalExportRequest }) { + const t = useTranslations("offlineExport"); + const tExport = useTranslations("mail_export"); + const tPolicy = useTranslations("device_policy"); + const locale = useLocale(); + const id = useId(); + const { account } = request; + const visible = useMailTransferStore((s) => s.visible); + const job = useMailTransferStore((s) => s.job); + const close = useMailTransferStore((s) => s.close); + const hide = useMailTransferStore((s) => s.hide); + const cancelJob = useMailTransferStore((s) => s.cancelJob); + const address = useAccountStore((s) => { + const entry = s.accounts.find((candidate) => candidate.id === account); + return entry?.email || entry?.username || account; + }); + // Re-renders when the connection changes; what it means is asked below. + useOfflineStore((s) => s.browserOffline); + useOfflineStore((s) => s.downServers); + const reachable = localExportReachable(account); + const [loaded, setLoaded] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [excluded, setExcluded] = useState>(new Set()); + const [format, setFormat] = useState("mbox"); + const [starting, setStarting] = useState(false); + const mode = saveMode(); + const progress = job?.kind === "export" ? job.progress : null; + const active = isJobActive(job); + const stageButton = useRef(null); + 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) setLoaded(result); }, + () => { if (live) setLoadFailed(true); }, + ); + return () => { live = false; }; + }, [account, inSetup]); + + if (!visible) return null; + + if (progress) { + const notKept = progress.notKept; + const notKeptCount = (notKept?.attachments_off ?? 0) + (notKept?.not_kept ?? 0); + const blocked = progress.phase === "failed" && !localExportAllowed(account); + return ( + (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 ? ( + <> + + + + ) : ( + <> + {progress.phase === "done" && progress.file?.download && ( + + )} + {notKeptCount > 0 && ( + + )} + + + )} + > + + {notKeptCount > 0 && ( +
+ {!!notKept?.attachments_off &&

{t("not_kept_attachments_off", { count: notKept.attachments_off })}

} + {!!notKept?.not_kept &&

{t("not_kept_not_kept", { count: notKept.not_kept })}

} +

{reachable ? t("retry_online") : t("retry_offline")}

+
+ )} +
+ ); + } + + 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; + + const onStart = async () => { + if (!loaded || starting || chosen.length === 0) return; + 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 ( + close()} + size="full-on-phone" + title={t("title")} + description={t("description")} + testId="local-export-dialog" + footer={( + <> + + + + )} + > +
+ {loadFailed ? ( +

{t("load_failed")}

+ ) : !loaded ? ( +

{t("loading")}

+ ) : ( + <> +
+ {t("folders_label")} + +
    + {rows.map((row) => ( +
  • + +
  • + ))} +
+
+
+ {tExport("format_label")} +
+ {formats.map((f) => ( + + ))} +
+
+
+ {total === 0 ? ( +

{t("nothing_kept")}

+ ) : ( +

{t("kept", { kept, total })}

+ )} + {missing > 0 && ( +

+ {reachable ? t("missing_online", { count: missing }) : t("missing_offline", { count: missing })} +

+ )} + {missing > 0 && !reachable && ( +

{attachmentsOff ? t("reason_attachments_off") : t("reason_not_kept")}

+ )} +
+

{tExport(`save_${mode}`)}

+ + )} +
+
+ ); +} diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx index 981fb2bdf..e98a865bf 100644 --- a/components/mail-transfer/mail-transfer-host.tsx +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -4,6 +4,8 @@ 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"; @@ -11,11 +13,27 @@ 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 { + const store = useMailTransferStore.getState(); + const job = store.job; + if (!job || !(accountId === "all" || !job.scope || job.scope === accountId)) return; + 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 }); +} + /** * 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 +49,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 +66,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"); @@ -77,6 +107,7 @@ export function MailTransferHost() { <> {request?.kind === "import" && } {request?.kind === "export" && } + {request?.kind === "local-export" && } {!visible && isJobActive(job) && } ); diff --git a/components/mail-transfer/start-local-export.ts b/components/mail-transfer/start-local-export.ts new file mode 100644 index 000000000..965033304 --- /dev/null +++ b/components/mail-transfer/start-local-export.ts @@ -0,0 +1,104 @@ +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 { 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"; + +const MAIL_CAPABILITY = "urn:ietf:params:jmap:mail"; + +/** + * Whether the organisation lets this account export. Unlike a browser online, a + * start offline may not have heard the organisation: not known means no. + */ +export function localExportAllowed(account: string): boolean { + const { loaded, partial, source } = usePolicyStore.getState(); + if (!loaded || partial || source === "none" || source === "fallback") return false; + return downloadAllowedQuietly("export", account); +} + +function clientOf(account: string) { + const auth = useAuthStore.getState(); + return auth.getClientForAccount?.(account) ?? (account === auth.activeAccountId ? auth.client : undefined) ?? null; +} + +/** The real client while its server can be reached. */ +function reachableClient(account: string) { + const client = clientOf(account); + if (!client || isOfflineNow(client.getServerUrl())) return null; + return client; +} + +/** Whether a message with no original here could be fetched right now. */ +export function localExportReachable(account: string): boolean { + return reachableClient(account) !== null; +} + +/** The account's own mail account in the store. Shared accounts aren't offered yet. */ +export async function primaryJmapAccount(account: string): Promise { + const session = await getStore().area(account, "mail.session"); + return session?.primaryAccounts?.[MAIL_CAPABILITY] ?? clientOf(account)?.getAccountId() ?? null; +} + +export function localExportReader(account: string, jmap: string) { + return localExportClient({ account, jmap, store: getStore(), online: () => reachableClient(account) }); +} + +export interface StartLocalExportInput { + account: string; + jmap: string; + format: ExportFormat; + folders: ExportFolder[]; + sink: ExportSink; +} + +/** Runs an export from the local store as the dialog's job. False, and nothing read, when the policy says no. */ +export function startLocalExport(input: StartLocalExportInput): boolean { + const { account, jmap, format, folders } = input; + if (!localExportAllowed(account) || !downloadAllowed("export", account)) { + void input.sink.abort().catch(() => {}); + return false; + } + const controller = new AbortController(); + // Stops at once, also for an account that isn't the active one, and when no write is due. + const unwatchPolicy = usePolicyStore.subscribe(() => { + if (!localExportAllowed(account)) controller.abort("policy"); + }); + const unwatchOffline = useOfflineStore.subscribe((state) => { + if (!state.enabled.includes(account)) controller.abort("offline-off"); + }); + let ended: () => void = () => {}; + const finished = new Promise((resolve) => { ended = resolve; }); + useMailTransferStore.getState().startJob({ + kind: "export", + controller, + scope: account, + finished, + progress: { phase: "running", exported: 0, failed: 0, failures: [], total: 0, fraction: 0 }, + }); + void (async () => { + try { + const result = await runExport({ + client: localExportReader(account, jmap), + accountId: jmap, + folders, + format, + // The save picker passes the guard in saveBlobAs, so every write asks. + sink: guardedSink(input.sink, () => localExportAllowed(account)), + signal: controller.signal, + requestsPerMinute: Infinity, + onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress), + }); + useMailTransferStore.getState().updateProgress(result); + } finally { + unwatchPolicy(); + unwatchOffline(); + ended(); + } + })(); + return true; +} diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index d58d07a1d..80666f60c 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -13,7 +13,9 @@ import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offl import { isOfflineNow, useOfflineStore } from '@/lib/offline/state'; import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress'; import { devicesForAccount } from '@/lib/device-policy'; +import { localExportAllowed } from '@/components/mail-transfer/start-local-export'; import { useAccountStore } from '@/stores/account-store'; +import { openLocalExport } from '@/stores/mail-transfer-store'; import { KeptFiles } from './kept-files'; const MB = 1024 * 1024; @@ -65,6 +67,7 @@ function useUnsent(account: string) { export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) { const t = useTranslations('offline.settings'); const tStorage = useTranslations('offlineStorage'); + const tExport = useTranslations('offlineExport'); const format = useFormatter(); const enabled = useOfflineStore((s) => s.enabled.includes(account)); const usage = useOfflineStore((s) => s.usage[account]); @@ -220,6 +223,9 @@ export function AccountStorage({ account, primary, heading }: { account: string; )} + + + diff --git a/integration/tests/24-do-export.spec.ts b/integration/tests/24-do-export.spec.ts new file mode 100644 index 000000000..cd0f6af91 --- /dev/null +++ b/integration/tests/24-do-export.spec.ts @@ -0,0 +1,300 @@ +/** + * Export of the local store, through the real UI, as ent-dave, 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. + * + * Run with desktop-offline-rig/run-spec.sh. 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 { ENT, ENT_PASSWORD, adminContext, jmapAs, requireEntEnv, shot, userPage, withPolicy } from './helpers/ent'; +import { JMAP_URL } from './helpers/config'; +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 = ENT.dave; +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}:${ENT_PASSWORD}`).toString('base64'); + +/** Bytes a rebuilt message wouldn't have: a Latin-1 byte, a bare LF, odd spacing, signature-like headers, mbox traps. */ +function rawMessage(n: number): Buffer { + return Buffer.concat([ + Buffer.from(`From: Seed \r\nTo: ${USER}\r\nSubject: ${SECRET} subject ${n} \r\nDate: Mon, 1 Jan 2018 08:00:0${n} +0000\r\nMessage-ID: <${STAMP}-${n}@example.org>\r\nDKIM-Signature: v=1; a=rsa-sha256; b=abc${n}\r\nContent-Type: text/plain; charset=iso-8859-1\r\n\r\n`), + Buffer.from([0xe4, 0x0a]), + Buffer.from(`From the start of a line ${n}\r\n>From quoted once\n${SECRET} body ${n}\r\n`), + ]); +} + +let mailbox = ''; +const blobIds: string[] = []; +const originals: Buffer[] = []; + +test.beforeAll(async () => { + const client = await jmapAs(USER); + 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 jmapAs(USER); + await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [mailbox], onDestroyRemoveEmails: true }, '0']]).catch(() => {}); +}); + +/** Every write to a private file is kept, and the export has no save picker, so it goes to the spool. */ +const INIT = () => { + const proto = FileSystemWritableFileStream.prototype as unknown as { write(data: unknown): Promise }; + const write = proto.write; + const seen: Uint8Array[] = []; + (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites = seen; + proto.write = function patched(data: unknown) { + if (data instanceof Uint8Array) seen.push(data.slice()); + return write.call(this, data); + }; + delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; +}; + +let page: Page; +let context: BrowserContext; + +async function settings(target: Page): Promise { + await target.goto('/settings/backup/offline'); + await expect(target.getByTestId('offline-switch')).toBeVisible({ timeout: 30_000 }); +} + +async function openDialog(): Promise { + await page.getByTestId('local-export-open').click(); + await expect(page.getByTestId('local-export-dialog')).toBeVisible(); + await expect(page.getByTestId('local-export-summary')).toBeVisible({ timeout: 30_000 }); + // The test's folder only. + await page.getByTestId('local-export-all').click(); + await page.locator(`[data-folder="${FOLDER}"]`).click(); +} + +async function closeSetup(): Promise { + await page.getByTestId('local-export-dialog').getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByTestId('local-export-dialog')).toBeHidden(); +} + +async function closeSummary(): Promise { + await page.getByTestId('local-export-dialog').getByRole('button', { name: 'Close', exact: true }).last().click(); + await expect(page.getByTestId('local-export-dialog')).toBeHidden(); +} + +async function waitForOriginals(wanted: string): Promise { + await expect.poll(async () => { + await openDialog(); + const text = await page.getByTestId('local-export-kept').textContent(); + await closeSetup(); + return text; + }, { timeout: 240_000, intervals: [5_000] }).toContain(wanted); +} + +async function exported(format: 'mbox' | 'eml'): Promise { + await page.getByTestId(`local-export-format-${format}`).check(); + const download = page.waitForEvent('download', { timeout: 60_000 }); + await page.getByTestId('local-export-start').click(); + const file = await download; + expect(file.suggestedFilename()).toBe(`${FOLDER}.${format === 'mbox' ? 'mbox' : 'zip'}`); + await expect(page.getByTestId('mail-export-status')).toBeVisible(); + return readFileSync(await file.path()); +} + +function mboxMessages(bytes: Buffer): Buffer[] { + const splitter = new MboxSplitter(); + return [...splitter.push(new Uint8Array(bytes)), ...splitter.end()].map((message) => Buffer.concat(withoutStateHeaders(message.parts))); +} + +function expectOriginals(messages: Buffer[]): void { + expect(messages.length).toBe(COUNT); + for (let i = 0; i < COUNT; i += 1) expect(messages[i].equals(originals[i]), `message ${i + 1} differs`).toBe(true); +} + +async function spoolReport(): Promise<{ left: string[]; bytes: number; readable: boolean }> { + return page.evaluate(async (secret) => { + const writes = (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites; + const decoder = new TextDecoder('latin1'); + const root = await navigator.storage.getDirectory(); + const left: string[] = []; + try { + const dir = await root.getDirectoryHandle('bulwark-exports'); + for await (const name of (dir as unknown as { keys(): AsyncIterable }).keys()) left.push(name); + } catch { /* never made */ } + return { + left, + bytes: writes.reduce((sum, write) => sum + write.length, 0), + readable: writes.some((write) => decoder.decode(write).includes(secret)), + }; + }, SECRET); +} + +test.beforeAll(async ({ browser }: { browser: Browser }) => { + page = await userPage(browser, USER); + context = page.context(); + 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 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.readable).toBe(false); + expect(report.left).toEqual([]); + await closeSummary(); +}); + +test('with a save picker, it writes straight to the chosen file and no private file is used', async () => { + const before = (await spoolReport()).bytes; + await page.evaluate(() => { + const parts: Uint8Array[] = []; + (window as unknown as { __picked: Uint8Array[] }).__picked = parts; + (window as unknown as { showSaveFilePicker: unknown }).showSaveFilePicker = async () => ({ + createWritable: async () => ({ + write: async (data: Uint8Array) => { parts.push(data.slice()); }, + close: async () => {}, + abort: async () => {}, + }), + }); + }); + 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}`); + const picked = await page.evaluate(() => { + const parts = (window as unknown as { __picked: Uint8Array[] }).__picked; + return parts.flatMap((part) => Array.from(part)); + }); + expectOriginals(mboxMessages(Buffer.from(picked))); + expect((await spoolReport()).bytes).toBe(before); + await closeSummary(); + await page.evaluate(() => { delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; }); +}); + +test('with attachments off, offline counts every message and says why; the retry online 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'); + await shot(page, 'd10-export-offline-missing'); + await page.getByTestId('local-export-format-mbox').check(); + const download = page.waitForEvent('download', { timeout: 60_000 }); + await page.getByTestId('local-export-start').click(); + await download; + await expect(page.getByTestId('local-export-not-kept-attachments')).toContainText(`attachments aren't kept on this device: ${COUNT}`); + 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 shot(page, 'd10-export-not-kept'); + + await context.setOffline(false); + await expect(page.getByTestId('local-export-retry')).toBeEnabled({ timeout: 30_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(); + + 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(); + await context.setOffline(true); + await expect(page.getByTestId('local-export-open')).toBeDisabled(); + await expect(page.getByTestId('local-export-dialog')).toHaveCount(0); + await context.setOffline(false); + }); + } finally { + await admin.close(); + } + await settings(page); + await expect(page.getByTestId('local-export-open')).toBeEnabled({ timeout: 30_000 }); +}); diff --git a/lib/desktop/taskbar-progress.ts b/lib/desktop/taskbar-progress.ts new file mode 100644 index 000000000..69098f37b --- /dev/null +++ b/lib/desktop/taskbar-progress.ts @@ -0,0 +1,13 @@ +import { getDesktopBridge } from './bridge'; + +type ProgressMode = 'normal' | 'indeterminate' | 'paused' | 'error'; + +/** + * The taskbar's progress bar: 0 to 1, or null to take it away. Does nothing in a + * browser, or in a shell with no `os.setProgress`, which arrives with + * feat/os-integration. When that merges, its setTaskbarProgress can replace this. + */ +export function showTaskbarProgress(progress: number | null, mode?: ProgressMode): void { + const bridge = getDesktopBridge() as { os?: { setProgress?: (progress: number | null, mode?: ProgressMode) => void } } | null; + bridge?.os?.setProgress?.(progress, mode); +} diff --git a/lib/mail-export/__tests__/local-store-export.test.ts b/lib/mail-export/__tests__/local-store-export.test.ts new file mode 100644 index 000000000..ea21f2982 --- /dev/null +++ b/lib/mail-export/__tests__/local-store-export.test.ts @@ -0,0 +1,261 @@ +// @vitest-environment node +/** + * An export from the local store, on real SQLite: the store keeps raw messages as it + * does in a browser, and what comes out is read back from the file. + */ +import { describe, expect, it, vi } from 'vitest'; +import { runExport, type ExportFormat, type ExportProgress } from '../engine'; +import { localExportClient } from '../local-store-client'; +import { memorySink, type ExportSink } from '../sinks'; +import { MboxSplitter } from '@/lib/mail-import/mbox'; +import { withoutStateHeaders } from '@/lib/mail-import/mbox-status'; +import { listZipEntries, readZipEntry } from '@/lib/mail-import/zip-reader'; +import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area'; +import { AccountEngine } from '@/lib/store/engine/account-engine'; +import { TransportError, type MethodCall } from '@/lib/store/engine/transport'; +import { mailArea } from '@/lib/store/mail'; +import { outboxArea } from '@/lib/store/outbox'; +import { FakeJmapDrafts } from '@/lib/store/__tests__/helpers/fake-drafts'; +import { FAKE_SESSION, JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap'; +import { openMemoryStore } from '@/lib/store/__tests__/helpers/node-adapter'; + +const SCOPE: StoreScope = { ...DEFAULT_SCOPE, days: null }; +const enc = new TextEncoder(); + +/** Bytes a rebuilt or re-encoded message wouldn't have: bare LF, CRLF, Latin-1, trailing space, an mbox trap. */ +function rawMessage(n: number): Uint8Array { + const head = enc.encode(`From: a${n}@example.org\r\nSubject: Secret subject ${n} \r\nDKIM-Signature: v=1; b=abc${n}\r\n\r\n`); + const body = enc.encode(`From the start of a line\n>From quoted once\r\nlast line ${n}\r\n`); + const out = new Uint8Array(head.length + 2 + body.length); + out.set(head); + out.set([0xe4, 0x0a], head.length); + out.set(body, head.length + 2); + return out; +} + +class Server extends FakeJmapDrafts { + readonly files = new Map(); + readonly fetched: string[] = []; + gone = new Set(); + + private read(blobId: string): Uint8Array { + this.fetched.push(blobId); + if (this.gone.has(blobId)) throw new TransportError('http', 'Gone', 404); + return this.files.get(blobId) ?? new Uint8Array(); + } + + override async download(_jmap?: string, blobId?: string): Promise { + return this.read(String(blobId)); + } + + async downloadStream(_jmap: string, blobId: string, _type: string, _name: string, sink: (part: Uint8Array) => void): Promise { + sink(this.read(blobId)); + } +} + +function serverWith(ids: Record): Server { + const server = new Server({ mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }, { id: 'archive', name: 'Archive' }] }); + let n = 0; + for (const [id, box] of Object.entries(ids)) { + n += 1; + const data = rawMessage(n); + server.files.set(`raw-${id}`, data); + server.addEmail({ id, blobId: `raw-${id}`, size: data.length, subject: `Secret subject ${n}`, mailboxIds: { [box]: true }, receivedAt: new Date(Date.UTC(2026, 9, n)).toISOString() }); + } + return server; +} + +async function storeOf(server: Server, scope: StoreScope = SCOPE, areas = [mailArea]) { + const engine = new AccountEngine({ + account: 'acc', + db: openMemoryStore(), + session: FAKE_SESSION, + transport: server, + areas, + scope, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); + await engine.requestSync(); + return { + engine, + store: { + answer: (_account: string, calls: MethodCall[]) => engine.answer(calls), + area: (_account: string, name: string, params?: unknown) => engine.areaCall(name, params) as Promise, + }, + }; +} + +function collecting(): { sink: ExportSink; bytes: () => Uint8Array } { + const inner = memorySink('x', 'application/octet-stream'); + const parts: Uint8Array[] = []; + return { + sink: { ...inner, write: async (chunk) => { parts.push(chunk.slice()); } }, + bytes: () => new Uint8Array(Buffer.concat(parts)), + }; +} + +type Store = Awaited>['store']; +type Online = Parameters[0]['online']; + +async function run(store: Store, format: ExportFormat, mailboxes: string[], online: Online = () => null): Promise<{ result: ExportProgress; bytes: Uint8Array }> { + const { sink, bytes } = collecting(); + const result = await runExport({ + client: localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store, online }), + accountId: JMAP_ACCOUNT, + folders: mailboxes.map((mailboxId) => ({ mailboxId, path: [mailboxId] })), + format, + sink, + signal: new AbortController().signal, + requestsPerMinute: Infinity, + sleep: async () => {}, + onProgress: () => {}, + }); + return { result, bytes: bytes() }; +} + +async function zipEntries(bytes: Uint8Array): Promise> { + const file = new Blob([bytes as BlobPart]); + const out = new Map(); + for (const entry of await listZipEntries(file)) out.set(entry.name, await readZipEntry(file, entry)); + return out; +} + +/** The messages of an mbox file with the quoting and state undone, the way the import reads it. */ +function mboxMessages(bytes: Uint8Array): Uint8Array[] { + const splitter = new MboxSplitter(); + const out = [...splitter.push(bytes), ...splitter.end()]; + return out.map((message) => new Uint8Array(Buffer.concat(withoutStateHeaders(message.parts)))); +} + +describe('an export from the local store', () => { + it('writes a folder offline as a ZIP of .eml files with the exact bytes the server has', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', other: 'archive' }); + const { store } = await storeOf(server); + const before = server.fetched.length; + const { result, bytes } = await run(store, 'eml', ['inbox']); + expect(result.phase).toBe('done'); + expect(result.exported).toBe(2); + expect(result.failed).toBe(0); + const entries = await zipEntries(bytes); + expect([...entries.keys()]).toEqual(['inbox/00001 Secret subject 1.eml', 'inbox/00002 Secret subject 2.eml']); + expect([...entries.values()][0]).toEqual(server.files.get('raw-a')); + expect([...entries.values()][1]).toEqual(server.files.get('raw-b')); + // Offline in truth: nothing was asked of the server. + expect(server.fetched.length).toBe(before); + }); + + it('writes a folder offline as an mbox whose messages read back as the exact bytes', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', other: 'archive' }); + const { store } = await storeOf(server); + const { result, bytes } = await run(store, 'mbox', ['inbox']); + expect(result.phase).toBe('done'); + const messages = mboxMessages(bytes); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual(server.files.get('raw-a')); + expect(messages[1]).toEqual(server.files.get('raw-b')); + }); + + it('counts what it has no original for by reason, and still writes the rest', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'inbox' }); + server.gone.add('raw-b'); + server.gone.add('raw-c'); + const { store } = await storeOf(server); + expect(await store.area('acc', 'mail.rawStatus', { jmap: JMAP_ACCOUNT, mailboxId: 'inbox' })).toEqual({ total: 3, kept: 1 }); + const { result, bytes } = await run(store, 'eml', ['inbox']); + expect(result.phase).toBe('done'); + expect(result.exported).toBe(1); + expect(result.failed).toBe(2); + expect(result.notKept).toEqual({ not_kept: 2 }); + // Counted, not a list of 2 errors the dialog would print one by one. + expect(result.failures).toEqual([]); + expect([...(await zipEntries(bytes)).values()]).toEqual([server.files.get('raw-a')]); + }); + + it('says it is because attachments are off when they are', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + const { store } = await storeOf(server, { ...SCOPE, attachments: false }); + const { result } = await run(store, 'mbox', ['inbox']); + expect(result.notKept).toEqual({ attachments_off: 2 }); + expect(result.exported).toBe(0); + }); + + it('fetches what isn\'t kept when online, one at a time or in a batch, and reads the rest from the store', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'inbox' }); + server.gone.add('raw-b'); + server.gone.add('raw-c'); + const { store } = await storeOf(server); + const fetchBlobForTransfer = vi.fn(async (blobId: string) => server.files.get(blobId)!); + const getBlobsAsBase64 = vi.fn(async (blobIds: string[]) => ({ + data: new Map([[blobIds[0], Buffer.from(server.files.get(blobIds[0])!).toString('base64')]]), + failed: [], + })); + const { result, bytes } = await run(store, 'eml', ['inbox'], () => ({ fetchBlobForTransfer, getBlobsAsBase64, getMaxConcurrentRequests: () => 4 })); + expect(result.exported).toBe(3); + expect(result.notKept).toBeUndefined(); + // The kept one isn't asked for, once it isn't missing from the batch. + expect(getBlobsAsBase64.mock.calls[0][0]).toEqual(['raw-b', 'raw-c']); + expect(fetchBlobForTransfer.mock.calls.map((call) => call[0])).toEqual(['raw-c']); + expect([...(await zipEntries(bytes)).values()]).toEqual([server.files.get('raw-a'), server.files.get('raw-b'), server.files.get('raw-c')]); + }); + + it('counts the rest as not kept when the connection goes during the run, instead of waiting on each', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'inbox' }); + for (const id of ['raw-a', 'raw-b', 'raw-c']) server.gone.add(id); + const { store } = await storeOf(server); + const fetchBlobForTransfer = vi.fn(async () => { throw new TypeError('Load failed'); }); + const { result } = await run(store, 'mbox', ['inbox'], () => ({ fetchBlobForTransfer })); + expect(result.phase).toBe('done'); + expect(result.notKept).toEqual({ not_kept: 3 }); + // Three tries in all, not three per message. + expect(fetchBlobForTransfer).toHaveBeenCalledTimes(3); + }); + + it('reports a server\'s refusal of one message as that message\'s failure', async () => { + const server = serverWith({ a: 'inbox' }); + server.gone.add('raw-a'); + const { store } = await storeOf(server); + const { result } = await run(store, 'mbox', ['inbox'], () => ({ + fetchBlobForTransfer: async () => { throw new Error('Failed to fetch blob: 403'); }, + })); + expect(result.notKept).toBeUndefined(); + expect(result.failures).toEqual([{ label: 'Secret subject 1', reason: 'Failed to fetch blob: 403' }]); + }); + + it('counts a draft written on this device, which has no original yet, and never asks the server for it', async () => { + const server = new Server({ mailboxes: [{ id: 'drafts', name: 'Drafts', role: 'drafts' }] }); + const { engine, store } = await storeOf(server, SCOPE, [mailArea, outboxArea]); + await engine.answer([['Email/set', { create: { c: { subject: 'Draft', keywords: { $draft: true }, mailboxIds: { drafts: true }, receivedAt: new Date().toISOString(), bodyValues: { 1: { value: 'x' } }, textBody: [{ partId: '1' }] } } }, 's']]); + const fetchBlobForTransfer = vi.fn(async () => new Uint8Array()); + const { result } = await run(store, 'mbox', ['drafts'], () => ({ fetchBlobForTransfer })); + expect(result.total).toBe(1); + expect(result.notKept).toEqual({ not_kept: 1 }); + expect(fetchBlobForTransfer).not.toHaveBeenCalled(); + }); + + it('reads the folder list and every message past one page', async () => { + const ids: Record = {}; + for (let i = 0; i < 503; i += 1) ids[`m${i}`] = 'inbox'; + const server = serverWith(ids); + for (let i = 0; i < 503; i += 1) server.gone.add(`raw-m${i}`); + const { store } = await storeOf(server, { ...SCOPE, attachments: false }); + const client = localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store, online: () => null }); + expect((await client.getMailboxes()).map((box) => box.id).sort()).toEqual(['archive', 'inbox']); + const list = await client.queryEmailFields({ inMailbox: 'inbox' }, ['blobId', 'subject'], JMAP_ACCOUNT, Number.MAX_SAFE_INTEGER); + expect(list).toHaveLength(503); + expect(new Set(list.map((email) => email.id)).size).toBe(503); + }); + + it('keeps to the server\'s pace only for what goes to the network', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + server.gone.add('raw-b'); + const { store } = await storeOf(server); + const take = vi.fn(async () => {}); + const fetchBlobForTransfer = vi.fn(async (blobId: string) => server.files.get(blobId)!); + const client = localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store, online: () => ({ fetchBlobForTransfer }), gate: { take } }); + await client.fetchBlobForTransfer!('raw-a', JMAP_ACCOUNT); + expect(take).not.toHaveBeenCalled(); + await client.fetchBlobForTransfer!('raw-b', JMAP_ACCOUNT); + expect(take).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/mail-export/engine.ts b/lib/mail-export/engine.ts index a49f5a724..802a75672 100644 --- a/lib/mail-export/engine.ts +++ b/lib/mail-export/engine.ts @@ -30,11 +30,26 @@ export interface ExportFolder { path: string[]; } +export type NotKeptReason = "attachments_off" | "not_kept"; + +/** + * A local-store export has no original of this message and can't reach the + * server. Counted by reason in `notKept`, not listed one by one. + */ +export class MessageNotKeptError extends Error { + constructor(readonly reason: NotKeptReason) { + super("The original of this message is not on this device"); + this.name = "MessageNotKeptError"; + } +} + export interface ExportProgress { phase: ExportPhase; exported: number; failed: number; failures: { label: string; reason: string }[]; + /** The part of `failed` a local-store export had no original for, by reason. */ + notKept?: Partial>; total: number; fraction: number; pausedUntil?: number; @@ -42,8 +57,11 @@ export interface ExportProgress { error?: string; } +/** What an export reads through: the real client, or lib/mail-export/local-store-client.ts. */ +export type ExportClient = Pick; + export interface ExportOptions { - client: IJMAPClient; + client: ExportClient; accountId: string; folders: ExportFolder[]; format: ExportFormat; @@ -53,7 +71,10 @@ export interface ExportOptions { now?: () => number; sleep?: (ms: number, signal: AbortSignal) => Promise; reportEveryMs?: number; - /** Requests per minute the export allows itself (the server's limit is 1000 for everything). */ + /** + * Requests per minute the export allows itself (the server's limit is 1000 for + * everything). Infinity for a client that keeps to the limit itself. + */ requestsPerMinute?: number; } @@ -141,10 +162,11 @@ export function folderTree(mailboxes: Mailbox[]): ExportFolder[] { } /** Keeps the export under a number of requests per minute. */ -class RateGate { +export class RateGate { private times: number[] = []; constructor(private readonly perMinute: number, private readonly now: () => number, private readonly sleep: (ms: number) => Promise) {} async take(): Promise { + if (this.perMinute === Infinity) return; for (;;) { const t = this.now(); this.times = this.times.filter((x) => t - x < 60_000); @@ -167,7 +189,7 @@ export async function runExport(options: ExportOptions): Promise if (!force && t - lastReport < (options.reportEveryMs ?? 150)) return; lastReport = t; progress.fraction = progress.total > 0 ? Math.min(1, (progress.exported + progress.failed) / progress.total) : 0; - options.onProgress({ ...progress, failures: [...progress.failures] }); + options.onProgress({ ...progress, failures: [...progress.failures], ...(progress.notKept ? { notKept: { ...progress.notKept } } : {}) }); }; const fail = (label: string, reason: string) => { progress.failed++; @@ -319,7 +341,14 @@ export async function runExport(options: ExportOptions): Promise const raw = results[i]; seq++; if (raw instanceof Error) { - fail(ref.subject, raw.message); + if (raw.name === "MessageNotKeptError") { + const { reason } = raw as MessageNotKeptError; + progress.failed++; + progress.notKept = { ...progress.notKept, [reason]: (progress.notKept?.[reason] ?? 0) + 1 }; + } else { + fail(ref.subject, raw.message); + } + report(); continue; } if (options.format === "mbox") { @@ -335,6 +364,8 @@ export async function runExport(options: ExportOptions): Promise } if (stream) await stream.close(); } + // A cancel with nothing left to fetch must not close and hand out the file. + if (signal.aborted) throw cancelled(); if (zip) await zip.finish(); progress.file = await sink.close(); progress.phase = "done"; diff --git a/lib/mail-export/local-store-client.ts b/lib/mail-export/local-store-client.ts new file mode 100644 index 000000000..9c4f555a3 --- /dev/null +++ b/lib/mail-export/local-store-client.ts @@ -0,0 +1,126 @@ +/** + * What an export reads from when it runs from the local store: the methods + * lib/mail-export/engine.ts uses, answered from the store. A message comes as the + * raw original the store kept, never rebuilt, since a rebuilt one loses signatures + * and exact bytes. For one that isn't kept, the real client fetches it as usual + * when the server can be reached. Otherwise it's a MessageNotKeptError, which the + * engine counts. + * + * Store reads have no rate limit, so the engine runs with no gate of its own and + * this keeps to the server's limit for what goes to the network. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { isStandInId, newStandInId } from '@/lib/jmap/stand-in-ids'; +import type { Mailbox } from '@/lib/jmap/types'; +import type { StoreBackend } from '@/lib/store/client/api'; +import type { MethodCall } from '@/lib/store/engine/transport'; +import { MessageNotKeptError, RateGate, type ExportClient, type NotKeptReason } from './engine'; + +const PAGE = 500; +const REQUESTS_PER_MINUTE = 600; +/** Failed downloads in a row before the rest of the run stops trying the network. */ +const NETWORK_TRIES = 3; + +type RawAnswer = { ok: true; blob: Blob } | { ok: false; reason: NotKeptReason }; + +export interface LocalExportClientOptions { + account: string; + /** The JMAP account in the store (not the local account id). */ + jmap: string; + store: Pick; + /** The real client if the server may be reached right now, else null. Asked for each message. */ + online: () => Partial> | null; + /** Keeps what goes to the network to the server's pace. A test can pass its own. */ + gate?: Pick; +} + +export type LocalExportClient = ExportClient & { getMailboxes(): Promise }; + +function isAbort(error: unknown): boolean { + return (error as { name?: string } | null)?.name === 'AbortError'; +} + +/** A fetch that never got an answer: TypeError in every browser, whatever it says, or the client's own time-out. */ +function isNetworkFailure(error: unknown): boolean { + if (isAbort(error)) return false; + const { name, message } = (error ?? {}) as { name?: string; message?: string }; + return name === 'TypeError' || /network|timed out/i.test(message ?? ''); +} + +function networkGate(): RateGate { + return new RateGate(REQUESTS_PER_MINUTE, Date.now, (ms) => new Promise((resolve) => setTimeout(resolve, ms))); +} + +export function localExportClient({ account, jmap, store, online, gate = networkGate() }: LocalExportClientOptions): LocalExportClient { + let networkMisses = 0; + const reachable = () => (networkMisses >= NETWORK_TRIES ? null : online()); + + const call = async (name: string, args: Record): Promise => { + const calls: MethodCall[] = [[name, { accountId: jmap, ...args }, '0']]; + const answer = await store.answer(account, calls); + const [method, result] = answer?.methodResponses?.[0] ?? []; + if (!answer || method === 'error') throw new Error(String((result as { description?: string; type?: string } | undefined)?.description ?? (result as { type?: string } | undefined)?.type ?? `${name} got no answer`)); + return result as T; + }; + + const fetchBlobForTransfer = async (blobId: string, _accountId: string | undefined, signal?: AbortSignal): Promise => { + // A draft written on this device: the server has never heard of it, and it has no original yet. + if (isStandInId(blobId)) throw new MessageNotKeptError('not_kept'); + const kept = await store.area(account, 'mail.rawBlob', { jmap, blobId }); + if (kept.ok) return new Uint8Array(await kept.blob.arrayBuffer()); + const real = reachable(); + if (!real?.fetchBlobForTransfer) throw new MessageNotKeptError(kept.reason); + await gate.take(); + try { + const bytes = await real.fetchBlobForTransfer(blobId, jmap, signal); + networkMisses = 0; + return bytes; + } catch (error) { + if (!isNetworkFailure(error)) throw error; + networkMisses += 1; + // The engine tries this again; by the last try this message counts as not kept. + throw new Error('network failure'); + } + }; + + return { + getMailboxes: async () => (await call<{ list: Mailbox[] }>('Mailbox/get', { ids: null })).list, + + queryEmailFields: async (filter, properties, _accountId, limit = Number.MAX_SAFE_INTEGER) => { + const emails: Record[] = []; + for (let position = 0; emails.length < limit;) { + const { ids } = await call<{ ids: string[] }>('Email/query', { + filter, + sort: [{ property: 'receivedAt', isAscending: false }], + position, + limit: Math.min(PAGE, limit - emails.length), + }); + if (ids.length === 0) break; + const { list } = await call<{ list: Record[] }>('Email/get', { ids, properties }); + // A message with no blobId still counts: the engine leaves such a row out silently. + emails.push(...list.map((email) => (typeof email.blobId === 'string' ? email : { ...email, blobId: newStandInId() }))); + position += ids.length; + if (ids.length < PAGE) break; + } + return emails as never; + }, + + getBlobsAsBase64: async (blobIds) => { + const real = reachable(); + const wanted = blobIds.filter((blobId) => !isStandInId(blobId)); + if (!real?.getBlobsAsBase64 || wanted.length === 0) return null; + const kept = new Set(await store.area(account, 'mail.rawKept', { jmap, blobIds: wanted })); + const missing = wanted.filter((blobId) => !kept.has(blobId)); + if (missing.length === 0) return null; + await gate.take(); + return real.getBlobsAsBase64(missing, jmap, { sized: true }); + }, + + fetchBlobForTransfer, + + fetchBlob: async (blobId) => new Blob([await fetchBlobForTransfer(blobId, jmap) as BlobPart]), + + getMaxConcurrentRequests: () => online()?.getMaxConcurrentRequests?.() ?? 4, + }; +} diff --git a/lib/offline/wipe-registry.ts b/lib/offline/wipe-registry.ts new file mode 100644 index 000000000..2b9d8f393 --- /dev/null +++ b/lib/offline/wipe-registry.ts @@ -0,0 +1,39 @@ +/** + * Where a feature with data of its own on this device signs up for wipes: + * edit-in-place's opened files, for one. Free of the stores, so anything can + * import it. Registering is all a feature has to do. + * + * A cleaner runs on every remote wipe, policy wipe, wipe from settings and + * refused login, before the account signs out. `account` is an account id, or + * `null` when everything on this device goes. A cleaner should finish quickly + * and may not ask the user anything. Bridge calls are fine, network isn't: a + * wipe often runs because the server said no. + */ + +export type WipeCleaner = (account: string | null) => void | Promise; + +const cleaners = new Set(); + +export function onDeviceWipe(cleaner: WipeCleaner): () => void { + cleaners.add(cleaner); + return () => cleaners.delete(cleaner); +} + +/** How long a wipe waits for one cleaner. What's on screen waits with it. */ +const CLEANER_BUDGET_MS = 10_000; + +export async function runWipeCleaners(account: string | null): Promise { + await Promise.all([...cleaners].map(async (cleaner) => { + let timer: ReturnType | undefined; + try { + await Promise.race([ + cleaner(account), + new Promise((resolve) => { timer = setTimeout(resolve, CLEANER_BUDGET_MS); }), + ]); + } catch { + // One feature failing must not keep the others' data. + } finally { + if (timer) clearTimeout(timer); + } + })); +} diff --git a/lib/store/mail/attachments.ts b/lib/store/mail/attachments.ts index 12feba84a..83a39d859 100644 --- a/lib/store/mail/attachments.ts +++ b/lib/store/mail/attachments.ts @@ -169,6 +169,17 @@ export function rawBlob(ctx: Pick, jmap: string, b return { ok: true, type: RAW_TYPE, size: kept.size, blob: new Blob([kept.blob], { type: RAW_TYPE }) }; } +/** Which of these whole messages are here, so an online export asks the server only for the rest. */ +export function rawKept(ctx: Pick, jmap: string, blobIds: readonly string[]): string[] { + if (!ctx.scope.attachments || blobIds.length === 0) return []; + const marks = blobIds.map(() => '?').join(', '); + return ctx.db.all<{ blob_id: string }>( + `SELECT DISTINCT p.blob_id FROM email_part p JOIN blob b ON b.jmap = p.jmap AND b.blob_id = p.blob_id + WHERE p.jmap = ? AND p.raw = 1 AND p.blob_id IN (${marks})`, + [jmap, ...blobIds], + ).map((row) => String(row.blob_id)); +} + /** How many messages (of one folder, or all) an export could write right now. */ export function rawStatus(ctx: Pick, jmap: string, mailboxId?: string): { total: number; kept: number } { const inFolder = mailboxId ? 'JOIN email_mailbox m ON m.email_rowid = e.rowid AND m.mailbox_id = ?' : ''; diff --git a/lib/store/mail/calls.ts b/lib/store/mail/calls.ts index b8202c86e..7139c395e 100644 --- a/lib/store/mail/calls.ts +++ b/lib/store/mail/calls.ts @@ -1,7 +1,7 @@ /** What the page asks the mail part of the store outside JMAP. */ import type { AreaCall, StoreContext } from '../area'; -import { keptBlob, rawBlob, rawStatus, recordKeptBlob } from './attachments'; +import { keptBlob, rawBlob, rawKept, rawStatus, recordKeptBlob } from './attachments'; import { storeBody, upsertEmail, type EmailObject } from './records'; /** @@ -55,6 +55,8 @@ export const MAIL_CALLS: Readonly> = { 'mail.getBlob': getBlob, /** A whole message for an offline export, or why not. */ 'mail.rawBlob': (ctx, params: { jmap: string; blobId: string }) => rawBlob(ctx, params.jmap, params.blobId), + /** Which of a batch are here: an online export fetches the others in one request. */ + 'mail.rawKept': (ctx, params: { jmap: string; blobIds: string[] }) => rawKept(ctx, params.jmap, params.blobIds), /** How many messages an export could write now, so it can say how many it can't. */ 'mail.rawStatus': (ctx, params: { jmap: string; mailboxId?: string }) => rawStatus(ctx, params.jmap, params.mailboxId), 'mail.usage': usage, diff --git a/locales/ar/common.json b/locales/ar/common.json index 6c467aa5c..dba61c101 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "المسودات التي رفضها الخادم: {count}", "banner_waiting": "مسودات في انتظار الخادم: {count}" }, + "offlineExport": { + "settings_label": "تصدير البريد المحفوظ", + "settings_description": "احفظ بريد هذا الجهاز كملفات mbox أو ‎.eml. يعمل ذلك دون اتصال أيضًا.", + "title": "تصدير البريد المحفوظ", + "description": "يحفظ هذا ما يحتفظ به هذا الجهاز كما هو تمامًا. البريد الأقدم من الفترة التي اخترتها غير مشمول. لحفظ كل ما على الخادم، استخدم «تصدير كل البريد».", + "folders_label": "المجلدات", + "all_folders": "كل المجلدات", + "folder_kept": "هنا: {kept} من {total}", + "loading": "جارٍ قراءة ما على هذا الجهاز…", + "load_failed": "تعذّرت قراءة البريد على هذا الجهاز.", + "nothing_kept": "لا يوجد بريد محفوظ على هذا الجهاز بعد.", + "kept": "النسخ الأصلية على هذا الجهاز: {kept} من {total} رسالة", + "missing_online": "للتنزيل من الخادم: {count}", + "missing_offline": "يتعذّر التصدير دون اتصال: {count}. يمكنك المحاولة مرة أخرى عند الاتصال.", + "reason_attachments_off": "لا تُحفظ الرسائل الأصلية إلا عند تشغيل «الاحتفاظ بالمرفقات».", + "reason_not_kept": "تُنزَّل الرسائل الأصلية في النهاية، وما دامت هناك مساحة لها فقط.", + "not_kept_attachments_off": "لا توجد نسخة أصلية لأن هذا الجهاز لا يحتفظ بالمرفقات: {count}", + "not_kept_not_kept": "لا توجد نسخة أصلية على هذا الجهاز بعد: {count}", + "retry": "إعادة المحاولة عند الاتصال", + "retry_online": "أنت متصل الآن، لذا يمكن جلبها من الخادم.", + "retry_offline": "يمكنك المحاولة مرة أخرى عند الاتصال.", + "settings_button": "تصدير…" + }, "deep_link": { "message_not_found": "لم تعد هذه الرسالة متاحة.", "thread_not_found": "لم تعد هذه المحادثة متاحة.", diff --git a/locales/ca/common.json b/locales/ca/common.json index f793ac547..1f54e077f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Esborranys rebutjats pel servidor: {count}", "banner_waiting": "Esborranys que esperen el servidor: {count}" }, + "offlineExport": { + "settings_label": "Exporta el correu desat", + "settings_description": "Desa el correu d'aquest dispositiu com a fitxers mbox o .eml. Funciona sense connexió.", + "title": "Exporta el correu desat", + "description": "Desa el que conserva aquest dispositiu, byte a byte. El correu anterior al període triat no hi és. Per desar-ho tot del servidor, fes servir «Exporta tot el correu».", + "folders_label": "Carpetes", + "all_folders": "Totes les carpetes", + "folder_kept": "{kept} de {total} aquí", + "loading": "S'està llegint el que hi ha en aquest dispositiu…", + "load_failed": "No s'ha pogut llegir el correu d'aquest dispositiu.", + "nothing_kept": "Encara no hi ha correu desat en aquest dispositiu.", + "kept": "Originals en aquest dispositiu: {kept} de {total} missatges", + "missing_online": "Per baixar del servidor: {count}", + "missing_offline": "No es poden exportar sense connexió: {count}. Pots tornar-ho a provar quan tinguis connexió.", + "reason_attachments_off": "Els missatges originals només es conserven si «Desa els fitxers adjunts» està activat.", + "reason_not_kept": "Els missatges originals es baixen al final, i només si queda espai.", + "not_kept_attachments_off": "Sense original aquí perquè aquest dispositiu no conserva adjunts: {count}", + "not_kept_not_kept": "Encara sense original en aquest dispositiu: {count}", + "retry": "Torna-ho a provar amb connexió", + "retry_online": "Ja tens connexió, així que poden venir del servidor.", + "retry_offline": "Pots tornar-ho a provar quan tinguis connexió.", + "settings_button": "Exporta…" + }, "deep_link": { "message_not_found": "Aquest missatge ja no està disponible.", "thread_not_found": "Aquesta conversa ja no està disponible.", diff --git a/locales/cs/common.json b/locales/cs/common.json index a873b79b5..a14d93cb8 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Koncepty odmítnuté serverem: {count}", "banner_waiting": "Koncepty čekající na server: {count}" }, + "offlineExport": { + "settings_label": "Exportovat uloženou poštu", + "settings_description": "Uložte poštu z tohoto zařízení jako soubory mbox nebo .eml. Funguje to i offline.", + "title": "Exportovat uloženou poštu", + "description": "Uloží to, co toto zařízení uchovává, bajt po bajtu. Pošta starší než zvolené období v tom není. Chcete-li uložit vše ze serveru, použijte „Exportovat všechnu poštu“.", + "folders_label": "Složky", + "all_folders": "Všechny složky", + "folder_kept": "{kept} z {total} zde", + "loading": "Čtení dat z tohoto zařízení…", + "load_failed": "Poštu na tomto zařízení se nepodařilo přečíst.", + "nothing_kept": "Na tomto zařízení zatím není uložená žádná pošta.", + "kept": "Originály na tomto zařízení: {kept} z {total} zpráv", + "missing_online": "Ke stažení ze serveru: {count}", + "missing_offline": "Nelze exportovat offline: {count}. Můžete to zkusit znovu, až budete online.", + "reason_attachments_off": "Původní zprávy se uchovávají jen tehdy, když je zapnuto „Uchovávat přílohy“.", + "reason_not_kept": "Původní zprávy se stahují jako poslední a jen tehdy, když je na ně místo.", + "not_kept_attachments_off": "Bez originálu, protože toto zařízení neuchovává přílohy: {count}", + "not_kept_not_kept": "Zatím bez originálu na tomto zařízení: {count}", + "retry": "Zkusit znovu online", + "retry_online": "Jste online, takže mohou přijít ze serveru.", + "retry_offline": "Můžete to zkusit znovu, až budete online.", + "settings_button": "Exportovat…" + }, "deep_link": { "message_not_found": "Tato zpráva už není dostupná.", "thread_not_found": "Tato konverzace už není dostupná.", diff --git a/locales/da/common.json b/locales/da/common.json index 58c79b036..65436fe69 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Kladder afvist af serveren: {count}", "banner_waiting": "Kladder, der venter på serveren: {count}" }, + "offlineExport": { + "settings_label": "Eksportér gemt post", + "settings_description": "Gem posten på denne enhed som mbox- eller .eml-filer. Det virker også offline.", + "title": "Eksportér gemt post", + "description": "Dette gemmer det, enheden opbevarer, byte for byte. Post, der er ældre end den valgte periode, er ikke med. Brug “Eksportér al mail” for at gemme alt fra serveren.", + "folders_label": "Mapper", + "all_folders": "Alle mapper", + "folder_kept": "{kept} af {total} her", + "loading": "Læser det, der ligger på denne enhed…", + "load_failed": "Posten på denne enhed kunne ikke læses.", + "nothing_kept": "Der er endnu ikke gemt post på denne enhed.", + "kept": "Originaler på denne enhed: {kept} af {total} beskeder", + "missing_online": "Skal hentes fra serveren: {count}", + "missing_offline": "Kan ikke eksporteres offline: {count}. Du kan prøve igen, når du er online.", + "reason_attachments_off": "Originale beskeder gemmes kun, når “Behold vedhæftede filer” er slået til.", + "reason_not_kept": "Originale beskeder hentes til sidst, og kun så længe der er plads.", + "not_kept_attachments_off": "Ingen original her, fordi vedhæftede filer ikke gemmes på denne enhed: {count}", + "not_kept_not_kept": "Endnu ingen original på denne enhed: {count}", + "retry": "Prøv igen online", + "retry_online": "Du er online nu, så de kan komme fra serveren.", + "retry_offline": "Du kan prøve igen, når du er online.", + "settings_button": "Eksportér…" + }, "deep_link": { "message_not_found": "Denne besked er ikke længere tilgængelig.", "thread_not_found": "Denne samtale er ikke længere tilgængelig.", diff --git a/locales/de/common.json b/locales/de/common.json index 0388c8994..82790cc10 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Vom Server abgelehnte Entwürfe: {count}", "banner_waiting": "Entwürfe warten auf den Server: {count}" }, + "offlineExport": { + "settings_label": "Gespeicherte E-Mails exportieren", + "settings_description": "Speichern Sie die E-Mails auf diesem Gerät als mbox- oder .eml-Dateien. Das geht auch offline.", + "title": "Gespeicherte E-Mails exportieren", + "description": "Das speichert, was dieses Gerät aufbewahrt, Byte für Byte. E-Mails, die älter als der gewählte Zeitraum sind, fehlen darin. Um alles auf dem Server zu speichern, nutzen Sie „Alle E-Mails exportieren“.", + "folders_label": "Ordner", + "all_folders": "Alle Ordner", + "folder_kept": "{kept} von {total} hier", + "loading": "Daten auf diesem Gerät werden gelesen…", + "load_failed": "Die E-Mails auf diesem Gerät konnten nicht gelesen werden.", + "nothing_kept": "Auf diesem Gerät sind noch keine E-Mails gespeichert.", + "kept": "Originale auf diesem Gerät: {kept} von {total} Nachrichten", + "missing_online": "Vom Server zu laden: {count}", + "missing_offline": "Offline nicht exportierbar: {count}. Sie können es erneut versuchen, sobald Sie online sind.", + "reason_attachments_off": "Originalnachrichten werden nur aufbewahrt, wenn „Anhänge behalten“ eingeschaltet ist.", + "reason_not_kept": "Originalnachrichten werden zuletzt geladen, und nur, solange Platz dafür ist.", + "not_kept_attachments_off": "Kein Original hier, weil Anhänge auf diesem Gerät nicht aufbewahrt werden: {count}", + "not_kept_not_kept": "Noch kein Original auf diesem Gerät: {count}", + "retry": "Online erneut versuchen", + "retry_online": "Sie sind jetzt online, diese Nachrichten können also vom Server kommen.", + "retry_offline": "Sie können es erneut versuchen, sobald Sie online sind.", + "settings_button": "Exportieren…" + }, "deep_link": { "message_not_found": "Diese Nachricht ist nicht mehr verfügbar.", "thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.", diff --git a/locales/en/common.json b/locales/en/common.json index c7cf59956..7dfbb4397 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Drafts the server refused: {count}", "banner_waiting": "Drafts waiting for the server: {count}" }, + "offlineExport": { + "settings_label": "Export kept mail", + "settings_description": "Save the mail on this device as mbox or .eml files. This works offline.", + "title": "Export kept mail", + "description": "This saves what this device keeps, byte for byte. Mail older than the period you chose isn't in it. To save everything on the server, use “Export all mail”.", + "folders_label": "Folders", + "all_folders": "All folders", + "folder_kept": "{kept} of {total} here", + "loading": "Reading what's on this device…", + "load_failed": "The mail on this device couldn't be read.", + "nothing_kept": "No mail is kept on this device yet.", + "kept": "Originals on this device: {kept} of {total} messages", + "missing_online": "To download from the server: {count}", + "missing_offline": "Can't be exported while you're offline: {count}. You can try again when you're online.", + "reason_attachments_off": "Original messages are only kept when “Keep attachments” is on.", + "reason_not_kept": "Original messages download last, and only while there's room for them.", + "not_kept_attachments_off": "No original here because attachments aren't kept on this device: {count}", + "not_kept_not_kept": "No original on this device yet: {count}", + "retry": "Try again online", + "retry_online": "You're online now, so these can come from the server.", + "retry_offline": "You can try again when you're online.", + "settings_button": "Export…" + }, "deep_link": { "message_not_found": "This message is no longer available.", "thread_not_found": "This conversation is no longer available.", diff --git a/locales/es/common.json b/locales/es/common.json index 35c0af539..9c06e9efb 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Borradores rechazados por el servidor: {count}", "banner_waiting": "Borradores esperando al servidor: {count}" }, + "offlineExport": { + "settings_label": "Exportar el correo guardado", + "settings_description": "Guarda el correo de este dispositivo como archivos mbox o .eml. Funciona sin conexión.", + "title": "Exportar el correo guardado", + "description": "Guarda lo que conserva este dispositivo, byte a byte. El correo anterior al periodo elegido no está incluido. Para guardar todo lo del servidor, usa «Exportar todo el correo».", + "folders_label": "Carpetas", + "all_folders": "Todas las carpetas", + "folder_kept": "{kept} de {total} aquí", + "loading": "Leyendo lo que hay en este dispositivo…", + "load_failed": "No se pudo leer el correo de este dispositivo.", + "nothing_kept": "Todavía no hay correo guardado en este dispositivo.", + "kept": "Originales en este dispositivo: {kept} de {total} mensajes", + "missing_online": "Por descargar del servidor: {count}", + "missing_offline": "No se pueden exportar sin conexión: {count}. Puedes volver a intentarlo cuando tengas conexión.", + "reason_attachments_off": "Los mensajes originales solo se guardan si «Guardar adjuntos» está activado.", + "reason_not_kept": "Los mensajes originales se descargan al final, y solo si queda espacio.", + "not_kept_attachments_off": "Sin original aquí porque este dispositivo no guarda adjuntos: {count}", + "not_kept_not_kept": "Todavía sin original en este dispositivo: {count}", + "retry": "Reintentar con conexión", + "retry_online": "Ya tienes conexión, así que estos pueden venir del servidor.", + "retry_offline": "Puedes volver a intentarlo cuando tengas conexión.", + "settings_button": "Exportar…" + }, "deep_link": { "message_not_found": "Este mensaje ya no está disponible.", "thread_not_found": "Esta conversación ya no está disponible.", diff --git a/locales/et/common.json b/locales/et/common.json index d0fc05409..6032131c7 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Serveri tagasi lükatud mustandid: {count}", "banner_waiting": "Serverit ootavad mustandid: {count}" }, + "offlineExport": { + "settings_label": "Hoitud kirjade eksport", + "settings_description": "Salvestage selle seadme kirjad mbox- või .eml-failidena. See töötab ka võrguühenduseta.", + "title": "Hoitud kirjade eksport", + "description": "Salvestab selle, mida seade hoiab, baidi täpsusega. Valitud ajavahemikust vanemaid kirju selles pole. Kõige salvestamiseks serverist kasutage valikut „Ekspordi kõik kirjad”.", + "folders_label": "Kaustad", + "all_folders": "Kõik kaustad", + "folder_kept": "Siin: {kept} / {total}", + "loading": "Selle seadme andmete lugemine…", + "load_failed": "Selle seadme kirju ei õnnestunud lugeda.", + "nothing_kept": "Selles seadmes pole veel ühtegi kirja hoitud.", + "kept": "Originaalid selles seadmes: {kept} / {total} kirja", + "missing_online": "Serverist alla laadida: {count}", + "missing_offline": "Võrguühenduseta ei saa eksportida: {count}. Proovige uuesti, kui olete võrgus.", + "reason_attachments_off": "Originaalkirju hoitakse ainult siis, kui „Hoia manuseid” on sees.", + "reason_not_kept": "Originaalkirjad laaditakse alla viimasena ja ainult siis, kui ruumi on.", + "not_kept_attachments_off": "Originaali pole, sest see seade ei hoia manuseid: {count}", + "not_kept_not_kept": "Selles seadmes pole veel originaali: {count}", + "retry": "Proovi uuesti võrgus", + "retry_online": "Olete nüüd võrgus, nii et need võivad tulla serverist.", + "retry_offline": "Proovige uuesti, kui olete võrgus.", + "settings_button": "Ekspordi…" + }, "deep_link": { "message_not_found": "See sõnum ei ole enam saadaval.", "thread_not_found": "See vestlus ei ole enam saadaval.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 5aef56be6..c5d189e36 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "پیش‌نویس‌های ردشده توسط سرور: {count}", "banner_waiting": "پیش‌نویس‌های در انتظار سرور: {count}" }, + "offlineExport": { + "settings_label": "برون‌بری نامه‌های نگه‌داشته‌شده", + "settings_description": "نامه‌های این دستگاه را به‌صورت فایل mbox یا ‎.eml ذخیره کنید. بدون اتصال هم کار می‌کند.", + "title": "برون‌بری نامه‌های نگه‌داشته‌شده", + "description": "آنچه این دستگاه نگه داشته است را عیناً ذخیره می‌کند. نامه‌های قدیمی‌تر از بازه‌ای که انتخاب کرده‌اید در آن نیستند. برای ذخیرهٔ همه‌چیز از سرور، از «برون‌بری همه نامه‌ها» استفاده کنید.", + "folders_label": "پوشه‌ها", + "all_folders": "همهٔ پوشه‌ها", + "folder_kept": "اینجا: {kept} از {total}", + "loading": "در حال خواندن داده‌های این دستگاه…", + "load_failed": "نامه‌های این دستگاه خوانده نشد.", + "nothing_kept": "هنوز نامه‌ای روی این دستگاه نگه داشته نشده است.", + "kept": "نسخه‌های اصلی روی این دستگاه: {kept} از {total} پیام", + "missing_online": "برای دانلود از سرور: {count}", + "missing_offline": "بدون اتصال برون‌بری نمی‌شود: {count}. وقتی آنلاین شدید دوباره امتحان کنید.", + "reason_attachments_off": "پیام‌های اصلی فقط وقتی نگه داشته می‌شوند که «نگه داشتن پیوست‌ها» روشن باشد.", + "reason_not_kept": "پیام‌های اصلی آخر از همه و فقط تا وقتی جا باشد دانلود می‌شوند.", + "not_kept_attachments_off": "نسخهٔ اصلی نیست چون این دستگاه پیوست‌ها را نگه نمی‌دارد: {count}", + "not_kept_not_kept": "هنوز نسخهٔ اصلی روی این دستگاه نیست: {count}", + "retry": "تلاش دوباره به‌صورت آنلاین", + "retry_online": "اکنون آنلاین هستید، پس می‌توانند از سرور بیایند.", + "retry_offline": "وقتی آنلاین شدید دوباره امتحان کنید.", + "settings_button": "برون‌بری…" + }, "deep_link": { "message_not_found": "این پیام دیگر در دسترس نیست.", "thread_not_found": "این گفتگو دیگر در دسترس نیست.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 277d30d79..d9f3cb91d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Brouillons refusés par le serveur : {count}", "banner_waiting": "Brouillons en attente du serveur : {count}" }, + "offlineExport": { + "settings_label": "Exporter le courrier conservé", + "settings_description": "Enregistrez le courrier de cet appareil en fichiers mbox ou .eml. Cela fonctionne hors ligne.", + "title": "Exporter le courrier conservé", + "description": "Enregistre ce que cet appareil conserve, à l'octet près. Le courrier plus ancien que la période choisie n'y figure pas. Pour tout enregistrer depuis le serveur, utilisez « Exporter tous les e-mails ».", + "folders_label": "Dossiers", + "all_folders": "Tous les dossiers", + "folder_kept": "{kept} sur {total} ici", + "loading": "Lecture des données de cet appareil…", + "load_failed": "Le courrier de cet appareil n'a pas pu être lu.", + "nothing_kept": "Aucun courrier n'est encore conservé sur cet appareil.", + "kept": "Originaux sur cet appareil : {kept} messages sur {total}", + "missing_online": "À télécharger depuis le serveur : {count}", + "missing_offline": "Exportation impossible hors ligne : {count}. Vous pourrez réessayer une fois en ligne.", + "reason_attachments_off": "Les messages originaux ne sont conservés que si « Garder les pièces jointes » est activé.", + "reason_not_kept": "Les messages originaux sont téléchargés en dernier, et seulement s'il reste de la place.", + "not_kept_attachments_off": "Pas d'original ici car les pièces jointes ne sont pas conservées sur cet appareil : {count}", + "not_kept_not_kept": "Pas encore d'original sur cet appareil : {count}", + "retry": "Réessayer en ligne", + "retry_online": "Vous êtes en ligne : ces messages peuvent venir du serveur.", + "retry_offline": "Vous pourrez réessayer une fois en ligne.", + "settings_button": "Exporter…" + }, "deep_link": { "message_not_found": "Ce message n'est plus disponible.", "thread_not_found": "Cette conversation n'est plus disponible.", diff --git a/locales/he/common.json b/locales/he/common.json index 58dfb56e7..0efa0b618 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5845,6 +5845,29 @@ "banner_refused": "טיוטות שהשרת דחה: {count}", "banner_waiting": "טיוטות שממתינות לשרת: {count}" }, + "offlineExport": { + "settings_label": "ייצוא דואר שנשמר", + "settings_description": "שמרו את הדואר שבמכשיר הזה כקובצי mbox או ‎.eml. זה עובד גם בלי חיבור.", + "title": "ייצוא דואר שנשמר", + "description": "שומר את מה שהמכשיר הזה מחזיק, בית אחר בית. דואר ישן יותר מהתקופה שבחרתם לא נכלל. כדי לשמור הכול מהשרת, השתמשו ב„ייצוא כל הדואר”.", + "folders_label": "תיקיות", + "all_folders": "כל התיקיות", + "folder_kept": "כאן: {kept} מתוך {total}", + "loading": "קורא את מה שיש במכשיר הזה…", + "load_failed": "לא ניתן היה לקרוא את הדואר במכשיר הזה.", + "nothing_kept": "עדיין לא נשמר דואר במכשיר הזה.", + "kept": "מקוריים במכשיר הזה: {kept} מתוך {total} הודעות", + "missing_online": "להורדה מהשרת: {count}", + "missing_offline": "לא ניתן לייצא בלי חיבור: {count}. אפשר לנסות שוב כשיהיה חיבור.", + "reason_attachments_off": "הודעות מקוריות נשמרות רק כש„שמירת קבצים מצורפים” פעיל.", + "reason_not_kept": "הודעות מקוריות יורדות אחרונות, ורק כל עוד יש מקום.", + "not_kept_attachments_off": "אין מקור כי המכשיר הזה לא שומר קבצים מצורפים: {count}", + "not_kept_not_kept": "עדיין אין מקור במכשיר הזה: {count}", + "retry": "לנסות שוב עם חיבור", + "retry_online": "יש חיבור עכשיו, אז הן יכולות להגיע מהשרת.", + "retry_offline": "אפשר לנסות שוב כשיהיה חיבור.", + "settings_button": "ייצוא…" + }, "deep_link": { "message_not_found": "הודעה זו אינה זמינה עוד.", "thread_not_found": "שיחה זו אינה זמינה עוד.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 410ba5ff5..e933fd336 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "A szerver által elutasított piszkozatok: {count}", "banner_waiting": "A szerverre váró piszkozatok: {count}" }, + "offlineExport": { + "settings_label": "Megőrzött levelek exportálása", + "settings_description": "Mentse az eszközön lévő leveleket mbox vagy .eml fájlként. Offline is működik.", + "title": "Megőrzött levelek exportálása", + "description": "Elmenti, amit ez az eszköz megőriz, bájtról bájtra. A választott időszaknál régebbi levelek nincsenek benne. A szerveren lévő összes levél mentéséhez használja ezt: „Az összes levél exportálása”.", + "folders_label": "Mappák", + "all_folders": "Minden mappa", + "folder_kept": "{kept} / {total} itt", + "loading": "Az eszközön lévő adatok olvasása…", + "load_failed": "Az eszközön lévő leveleket nem sikerült beolvasni.", + "nothing_kept": "Ezen az eszközön még nincsenek megőrzött levelek.", + "kept": "Eredetik ezen az eszközön: {kept} / {total} üzenet", + "missing_online": "A szerverről letöltendő: {count}", + "missing_offline": "Offline nem exportálható: {count}. Ha újra online lesz, megpróbálhatja újra.", + "reason_attachments_off": "Az eredeti üzeneteket csak akkor őrzi meg az eszköz, ha be van kapcsolva ez: „Mellékletek megtartása”.", + "reason_not_kept": "Az eredeti üzenetek utoljára töltődnek le, és csak addig, amíg van hely.", + "not_kept_attachments_off": "Nincs itt eredeti, mert ez az eszköz nem őrzi meg a mellékleteket: {count}", + "not_kept_not_kept": "Még nincs eredeti ezen az eszközön: {count}", + "retry": "Újra online", + "retry_online": "Most online van, így ezek jöhetnek a szerverről.", + "retry_offline": "Ha újra online lesz, megpróbálhatja újra.", + "settings_button": "Exportálás…" + }, "deep_link": { "message_not_found": "Ez az üzenet már nem érhető el.", "thread_not_found": "Ez a beszélgetés már nem érhető el.", diff --git a/locales/it/common.json b/locales/it/common.json index 051aca7a3..e301258ed 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Bozze rifiutate dal server: {count}", "banner_waiting": "Bozze in attesa del server: {count}" }, + "offlineExport": { + "settings_label": "Esporta la posta conservata", + "settings_description": "Salva la posta di questo dispositivo come file mbox o .eml. Funziona anche offline.", + "title": "Esporta la posta conservata", + "description": "Salva ciò che questo dispositivo conserva, byte per byte. La posta più vecchia del periodo scelto non è inclusa. Per salvare tutto dal server, usa «Esporta tutta la posta».", + "folders_label": "Cartelle", + "all_folders": "Tutte le cartelle", + "folder_kept": "{kept} di {total} qui", + "loading": "Lettura dei dati su questo dispositivo…", + "load_failed": "Impossibile leggere la posta su questo dispositivo.", + "nothing_kept": "Su questo dispositivo non è ancora conservata posta.", + "kept": "Originali su questo dispositivo: {kept} di {total} messaggi", + "missing_online": "Da scaricare dal server: {count}", + "missing_offline": "Non esportabili offline: {count}. Puoi riprovare quando sei online.", + "reason_attachments_off": "I messaggi originali vengono conservati solo se «Conserva gli allegati» è attivo.", + "reason_not_kept": "I messaggi originali vengono scaricati per ultimi e solo se c'è spazio.", + "not_kept_attachments_off": "Nessun originale qui perché su questo dispositivo gli allegati non vengono conservati: {count}", + "not_kept_not_kept": "Ancora nessun originale su questo dispositivo: {count}", + "retry": "Riprova online", + "retry_online": "Ora sei online, quindi possono arrivare dal server.", + "retry_offline": "Puoi riprovare quando sei online.", + "settings_button": "Esporta…" + }, "deep_link": { "message_not_found": "Questo messaggio non è più disponibile.", "thread_not_found": "Questa conversazione non è più disponibile.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3b7a425db..53240591a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "サーバーが拒否した下書き: {count}", "banner_waiting": "サーバーを待っている下書き: {count}" }, + "offlineExport": { + "settings_label": "保存済みメールをエクスポート", + "settings_description": "このデバイスのメールを mbox または .eml ファイルとして保存します。オフラインでも使えます。", + "title": "保存済みメールをエクスポート", + "description": "このデバイスに保存されているものをそのまま書き出します。選んだ期間より古いメールは含まれません。サーバーのすべてを保存するには「すべてのメールをエクスポート」を使ってください。", + "folders_label": "フォルダー", + "all_folders": "すべてのフォルダー", + "folder_kept": "ここに {kept} / {total}", + "loading": "このデバイスのデータを読み込んでいます…", + "load_failed": "このデバイスのメールを読み込めませんでした。", + "nothing_kept": "このデバイスにはまだメールが保存されていません。", + "kept": "このデバイスにある原本: {kept} / {total} 件", + "missing_online": "サーバーからダウンロード: {count}", + "missing_offline": "オフラインではエクスポートできません: {count}。オンラインになったらもう一度お試しください。", + "reason_attachments_off": "元のメッセージは「添付ファイルを保存」がオンのときだけ保存されます。", + "reason_not_kept": "元のメッセージは最後に、空きがある間だけダウンロードされます。", + "not_kept_attachments_off": "このデバイスに添付ファイルを保存していないため原本なし: {count}", + "not_kept_not_kept": "このデバイスにまだ原本なし: {count}", + "retry": "オンラインでもう一度試す", + "retry_online": "オンラインなので、サーバーから取得できます。", + "retry_offline": "オンラインになったらもう一度お試しください。", + "settings_button": "エクスポート…" + }, "deep_link": { "message_not_found": "このメッセージは利用できなくなりました。", "thread_not_found": "この会話は利用できなくなりました。", diff --git a/locales/ko/common.json b/locales/ko/common.json index b453ba6e2..057bff4cb 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "서버가 거부한 임시 보관 메일: {count}", "banner_waiting": "서버를 기다리는 임시 보관 메일: {count}" }, + "offlineExport": { + "settings_label": "보관된 메일 내보내기", + "settings_description": "이 기기의 메일을 mbox 또는 .eml 파일로 저장합니다. 오프라인에서도 됩니다.", + "title": "보관된 메일 내보내기", + "description": "이 기기에 보관된 내용을 그대로 저장합니다. 선택한 기간보다 오래된 메일은 포함되지 않습니다. 서버의 모든 메일을 저장하려면 “모든 메일 내보내기”을(를) 사용하세요.", + "folders_label": "폴더", + "all_folders": "모든 폴더", + "folder_kept": "여기에 {kept}/{total}", + "loading": "이 기기의 데이터를 읽는 중…", + "load_failed": "이 기기의 메일을 읽지 못했습니다.", + "nothing_kept": "이 기기에 아직 보관된 메일이 없습니다.", + "kept": "이 기기의 원본: {kept}/{total}개 메시지", + "missing_online": "서버에서 다운로드: {count}", + "missing_offline": "오프라인에서는 내보낼 수 없음: {count}. 온라인이 되면 다시 시도할 수 있습니다.", + "reason_attachments_off": "원본 메시지는 “첨부 파일 보관”이(가) 켜져 있을 때만 보관됩니다.", + "reason_not_kept": "원본 메시지는 맨 마지막에, 공간이 있을 때만 다운로드됩니다.", + "not_kept_attachments_off": "이 기기에 첨부 파일을 보관하지 않아 원본 없음: {count}", + "not_kept_not_kept": "이 기기에 아직 원본 없음: {count}", + "retry": "온라인에서 다시 시도", + "retry_online": "지금 온라인이므로 서버에서 가져올 수 있습니다.", + "retry_offline": "온라인이 되면 다시 시도할 수 있습니다.", + "settings_button": "내보내기…" + }, "deep_link": { "message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.", "thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 06ae58e27..25a58a069 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Servera noraidītie melnraksti: {count}", "banner_waiting": "Melnraksti, kas gaida serveri: {count}" }, + "offlineExport": { + "settings_label": "Eksportēt saglabāto pastu", + "settings_description": "Saglabājiet šīs ierīces pastu kā mbox vai .eml failus. Darbojas arī bezsaistē.", + "title": "Eksportēt saglabāto pastu", + "description": "Saglabā to, ko glabā šī ierīce, baitu pa baitam. Pasta, kas vecāks par izvēlēto periodu, tajā nav. Lai saglabātu visu no servera, izmantojiet “Eksportēt visu pastu”.", + "folders_label": "Mapes", + "all_folders": "Visas mapes", + "folder_kept": "Šeit: {kept} no {total}", + "loading": "Tiek lasīti šīs ierīces dati…", + "load_failed": "Neizdevās nolasīt šīs ierīces pastu.", + "nothing_kept": "Šajā ierīcē vēl nav saglabāta pasta.", + "kept": "Oriģināli šajā ierīcē: {kept} no {total} ziņojumiem", + "missing_online": "Jālejupielādē no servera: {count}", + "missing_offline": "Bezsaistē nevar eksportēt: {count}. Varat mēģināt vēlreiz, kad būsiet tiešsaistē.", + "reason_attachments_off": "Oriģinālie ziņojumi tiek glabāti tikai tad, ja ir ieslēgts “Glabāt pielikumus”.", + "reason_not_kept": "Oriģinālie ziņojumi tiek lejupielādēti pēdējie un tikai tad, ja ir vieta.", + "not_kept_attachments_off": "Oriģināla nav, jo šī ierīce neglabā pielikumus: {count}", + "not_kept_not_kept": "Šajā ierīcē vēl nav oriģināla: {count}", + "retry": "Mēģināt vēlreiz tiešsaistē", + "retry_online": "Tagad esat tiešsaistē, tāpēc tie var nākt no servera.", + "retry_offline": "Varat mēģināt vēlreiz, kad būsiet tiešsaistē.", + "settings_button": "Eksportēt…" + }, "deep_link": { "message_not_found": "Šī ziņa vairs nav pieejama.", "thread_not_found": "Šī sarakste vairs nav pieejama.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 59782e7af..1793a5a5f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Серверийн татгалзсан ноорог: {count}", "banner_waiting": "Серверийг хүлээж буй ноорог: {count}" }, + "offlineExport": { + "settings_label": "Хадгалсан захидлыг экспортлох", + "settings_description": "Энэ төхөөрөмжийн захидлыг mbox эсвэл .eml файлаар хадгална. Офлайн үед ч ажиллана.", + "title": "Хадгалсан захидлыг экспортлох", + "description": "Энэ төхөөрөмжид хадгалагдсан зүйлийг яг байгаагаар нь хадгална. Сонгосон хугацаанаас хуучин захидал орохгүй. Серверт байгаа бүгдийг хадгалахын тулд “Бүх шуудан экспортлох”-г ашиглана уу.", + "folders_label": "Хавтаснууд", + "all_folders": "Бүх хавтас", + "folder_kept": "Энд: {total}-с {kept}", + "loading": "Энэ төхөөрөмжийн өгөгдлийг уншиж байна…", + "load_failed": "Энэ төхөөрөмжийн захидлыг уншиж чадсангүй.", + "nothing_kept": "Энэ төхөөрөмжид хадгалсан захидал одоогоор алга.", + "kept": "Энэ төхөөрөмж дээрх эх хувь: {total}-с {kept}", + "missing_online": "Серверээс татах: {count}", + "missing_offline": "Офлайн үед экспортлох боломжгүй: {count}. Онлайн болсны дараа дахин оролдоно уу.", + "reason_attachments_off": "Эх захидлуудыг зөвхөн “Хавсралтуудыг хадгалах” асаалттай үед хадгална.", + "reason_not_kept": "Эх захидлууд хамгийн сүүлд, зөвхөн зай байвал татагдана.", + "not_kept_attachments_off": "Энэ төхөөрөмж хавсралт хадгалдаггүй тул эх хувь алга: {count}", + "not_kept_not_kept": "Энэ төхөөрөмжид эх хувь одоогоор алга: {count}", + "retry": "Онлайнаар дахин оролдох", + "retry_online": "Та одоо онлайн байгаа тул серверээс авч болно.", + "retry_offline": "Онлайн болсны дараа дахин оролдоно уу.", + "settings_button": "Экспортлох…" + }, "deep_link": { "message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.", "thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 460ed0217..d2d4497da 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Utkast avvist av serveren: {count}", "banner_waiting": "Utkast som venter på serveren: {count}" }, + "offlineExport": { + "settings_label": "Eksporter lagret e-post", + "settings_description": "Lagre e-posten på denne enheten som mbox- eller .eml-filer. Dette virker også frakoblet.", + "title": "Eksporter lagret e-post", + "description": "Dette lagrer det enheten beholder, byte for byte. E-post eldre enn perioden du valgte, er ikke med. Bruk «Eksporter all e-post» for å lagre alt fra serveren.", + "folders_label": "Mapper", + "all_folders": "Alle mapper", + "folder_kept": "{kept} av {total} her", + "loading": "Leser det som ligger på denne enheten…", + "load_failed": "Kunne ikke lese e-posten på denne enheten.", + "nothing_kept": "Ingen e-post er lagret på denne enheten ennå.", + "kept": "Originaler på denne enheten: {kept} av {total} meldinger", + "missing_online": "Skal lastes ned fra serveren: {count}", + "missing_offline": "Kan ikke eksporteres frakoblet: {count}. Du kan prøve igjen når du er tilkoblet.", + "reason_attachments_off": "Originale meldinger beholdes bare når «Behold vedlegg» er på.", + "reason_not_kept": "Originale meldinger lastes ned sist, og bare så lenge det er plass.", + "not_kept_attachments_off": "Ingen original her fordi vedlegg ikke beholdes på denne enheten: {count}", + "not_kept_not_kept": "Ingen original på denne enheten ennå: {count}", + "retry": "Prøv igjen tilkoblet", + "retry_online": "Du er tilkoblet nå, så disse kan komme fra serveren.", + "retry_offline": "Du kan prøve igjen når du er tilkoblet.", + "settings_button": "Eksporter…" + }, "deep_link": { "message_not_found": "Denne meldingen er ikke lenger tilgjengelig.", "thread_not_found": "Denne samtalen er ikke lenger tilgjengelig.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8b260a849..f0c96dd0d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Door de server geweigerde concepten: {count}", "banner_waiting": "Concepten die op de server wachten: {count}" }, + "offlineExport": { + "settings_label": "Bewaarde e-mail exporteren", + "settings_description": "Sla de e-mail op dit apparaat op als mbox- of .eml-bestanden. Dit werkt ook offline.", + "title": "Bewaarde e-mail exporteren", + "description": "Dit slaat op wat dit apparaat bewaart, byte voor byte. E-mail ouder dan de gekozen periode zit er niet bij. Gebruik “Alle e-mail exporteren” om alles van de server op te slaan.", + "folders_label": "Mappen", + "all_folders": "Alle mappen", + "folder_kept": "{kept} van {total} hier", + "loading": "Gegevens op dit apparaat lezen…", + "load_failed": "De e-mail op dit apparaat kon niet worden gelezen.", + "nothing_kept": "Op dit apparaat is nog geen e-mail bewaard.", + "kept": "Originelen op dit apparaat: {kept} van {total} berichten", + "missing_online": "Te downloaden van de server: {count}", + "missing_offline": "Niet te exporteren terwijl u offline bent: {count}. U kunt het opnieuw proberen als u online bent.", + "reason_attachments_off": "Originele berichten worden alleen bewaard als “Bijlagen bewaren” aan staat.", + "reason_not_kept": "Originele berichten worden als laatste gedownload, en alleen als er ruimte voor is.", + "not_kept_attachments_off": "Geen origineel hier omdat bijlagen niet op dit apparaat worden bewaard: {count}", + "not_kept_not_kept": "Nog geen origineel op dit apparaat: {count}", + "retry": "Opnieuw proberen online", + "retry_online": "U bent nu online, dus deze kunnen van de server komen.", + "retry_offline": "U kunt het opnieuw proberen als u online bent.", + "settings_button": "Exporteren…" + }, "deep_link": { "message_not_found": "Dit bericht is niet meer beschikbaar.", "thread_not_found": "Dit gesprek is niet meer beschikbaar.", diff --git a/locales/pl/common.json b/locales/pl/common.json index f8c9161f9..54a373e0c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Wersje robocze odrzucone przez serwer: {count}", "banner_waiting": "Wersje robocze czekające na serwer: {count}" }, + "offlineExport": { + "settings_label": "Eksportuj zachowaną pocztę", + "settings_description": "Zapisz pocztę z tego urządzenia jako pliki mbox lub .eml. Działa to też offline.", + "title": "Eksportuj zachowaną pocztę", + "description": "Zapisuje to, co przechowuje to urządzenie, bajt w bajt. Poczty starszej niż wybrany okres tu nie ma. Aby zapisać wszystko z serwera, użyj opcji „Eksportuj całą pocztę”.", + "folders_label": "Foldery", + "all_folders": "Wszystkie foldery", + "folder_kept": "{kept} z {total} tutaj", + "loading": "Odczytywanie danych z tego urządzenia…", + "load_failed": "Nie udało się odczytać poczty na tym urządzeniu.", + "nothing_kept": "Na tym urządzeniu nie ma jeszcze zachowanej poczty.", + "kept": "Oryginały na tym urządzeniu: {kept} z {total} wiadomości", + "missing_online": "Do pobrania z serwera: {count}", + "missing_offline": "Nie można wyeksportować offline: {count}. Spróbuj ponownie, gdy będziesz online.", + "reason_attachments_off": "Oryginalne wiadomości są zachowywane tylko wtedy, gdy opcja „Przechowuj załączniki” jest włączona.", + "reason_not_kept": "Oryginalne wiadomości są pobierane na końcu i tylko wtedy, gdy jest na nie miejsce.", + "not_kept_attachments_off": "Brak oryginału, bo załączniki nie są zachowywane na tym urządzeniu: {count}", + "not_kept_not_kept": "Jeszcze bez oryginału na tym urządzeniu: {count}", + "retry": "Spróbuj ponownie online", + "retry_online": "Masz połączenie, więc mogą przyjść z serwera.", + "retry_offline": "Spróbuj ponownie, gdy będziesz online.", + "settings_button": "Eksportuj…" + }, "deep_link": { "message_not_found": "Ta wiadomość nie jest już dostępna.", "thread_not_found": "Ta rozmowa nie jest już dostępna.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 0b2a251fc..e3f369efa 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Rascunhos recusados pelo servidor: {count}", "banner_waiting": "Rascunhos a aguardar o servidor: {count}" }, + "offlineExport": { + "settings_label": "Exportar correio guardado", + "settings_description": "Guarde o correio deste dispositivo em ficheiros mbox ou .eml. Funciona sem ligação.", + "title": "Exportar correio guardado", + "description": "Guarda o que este dispositivo conserva, byte a byte. O correio mais antigo do que o período escolhido não está incluído. Para guardar tudo do servidor, use “Exportar todos os e-mails”.", + "folders_label": "Pastas", + "all_folders": "Todas as pastas", + "folder_kept": "{kept} de {total} aqui", + "loading": "A ler o que está neste dispositivo…", + "load_failed": "Não foi possível ler o correio deste dispositivo.", + "nothing_kept": "Ainda não há correio guardado neste dispositivo.", + "kept": "Originais neste dispositivo: {kept} de {total} mensagens", + "missing_online": "A transferir do servidor: {count}", + "missing_offline": "Não podem ser exportadas sem ligação: {count}. Pode tentar de novo quando tiver ligação.", + "reason_attachments_off": "As mensagens originais só são guardadas quando “Manter anexos” está ativo.", + "reason_not_kept": "As mensagens originais são transferidas por último, e só enquanto houver espaço.", + "not_kept_attachments_off": "Sem original aqui porque este dispositivo não guarda anexos: {count}", + "not_kept_not_kept": "Ainda sem original neste dispositivo: {count}", + "retry": "Tentar de novo com ligação", + "retry_online": "Já tem ligação, por isso estas podem vir do servidor.", + "retry_offline": "Pode tentar de novo quando tiver ligação.", + "settings_button": "Exportar…" + }, "deep_link": { "message_not_found": "Esta mensagem não está mais disponível.", "thread_not_found": "Esta conversa não está mais disponível.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 7186c3627..409029675 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Ciorne refuzate de server: {count}", "banner_waiting": "Ciorne care așteaptă serverul: {count}" }, + "offlineExport": { + "settings_label": "Exportă mesajele păstrate", + "settings_description": "Salvați mesajele de pe acest dispozitiv ca fișiere mbox sau .eml. Funcționează și offline.", + "title": "Exportă mesajele păstrate", + "description": "Salvează ce păstrează acest dispozitiv, octet cu octet. Mesajele mai vechi decât perioada aleasă nu sunt incluse. Pentru a salva tot de pe server, folosiți „Exportă toate e-mailurile”.", + "folders_label": "Dosare", + "all_folders": "Toate dosarele", + "folder_kept": "{kept} din {total} aici", + "loading": "Se citesc datele de pe acest dispozitiv…", + "load_failed": "Mesajele de pe acest dispozitiv nu au putut fi citite.", + "nothing_kept": "Pe acest dispozitiv nu sunt încă mesaje păstrate.", + "kept": "Originale pe acest dispozitiv: {kept} din {total} mesaje", + "missing_online": "De descărcat de pe server: {count}", + "missing_offline": "Nu pot fi exportate offline: {count}. Puteți încerca din nou când sunteți online.", + "reason_attachments_off": "Mesajele originale sunt păstrate doar când „Păstrează atașamentele” este activat.", + "reason_not_kept": "Mesajele originale se descarcă ultimele și doar cât timp este loc.", + "not_kept_attachments_off": "Fără original aici, deoarece atașamentele nu sunt păstrate pe acest dispozitiv: {count}", + "not_kept_not_kept": "Încă fără original pe acest dispozitiv: {count}", + "retry": "Încearcă din nou online", + "retry_online": "Sunteți online acum, deci pot veni de pe server.", + "retry_offline": "Puteți încerca din nou când sunteți online.", + "settings_button": "Exportă…" + }, "deep_link": { "message_not_found": "Acest mesaj nu mai este disponibil.", "thread_not_found": "Această conversație nu mai este disponibilă.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 05b7baaab..78f5cfd37 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Черновики, отклонённые сервером: {count}", "banner_waiting": "Черновики, ожидающие сервер: {count}" }, + "offlineExport": { + "settings_label": "Экспорт сохранённой почты", + "settings_description": "Сохраните почту с этого устройства в файлы mbox или .eml. Работает и без сети.", + "title": "Экспорт сохранённой почты", + "description": "Сохраняет то, что хранится на этом устройстве, байт в байт. Почты старше выбранного периода здесь нет. Чтобы сохранить всё с сервера, используйте «Экспортировать всю почту».", + "folders_label": "Папки", + "all_folders": "Все папки", + "folder_kept": "Здесь: {kept} из {total}", + "loading": "Чтение данных на этом устройстве…", + "load_failed": "Не удалось прочитать почту на этом устройстве.", + "nothing_kept": "На этом устройстве ещё нет сохранённой почты.", + "kept": "Оригиналы на этом устройстве: {kept} из {total}", + "missing_online": "Загрузить с сервера: {count}", + "missing_offline": "Нельзя экспортировать без сети: {count}. Повторите попытку, когда появится сеть.", + "reason_attachments_off": "Исходные сообщения хранятся, только если включено «Хранить вложения».", + "reason_not_kept": "Исходные сообщения загружаются в последнюю очередь и только пока есть место.", + "not_kept_attachments_off": "Нет оригинала, потому что на этом устройстве не хранятся вложения: {count}", + "not_kept_not_kept": "Пока нет оригинала на этом устройстве: {count}", + "retry": "Повторить в сети", + "retry_online": "Сеть есть, поэтому их можно получить с сервера.", + "retry_offline": "Повторите попытку, когда появится сеть.", + "settings_button": "Экспортировать…" + }, "deep_link": { "message_not_found": "Это письмо больше недоступно.", "thread_not_found": "Эта переписка больше недоступна.", diff --git a/locales/sk/common.json b/locales/sk/common.json index a21785e59..bc15cd35c 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Koncepty odmietnuté serverom: {count}", "banner_waiting": "Koncepty čakajúce na server: {count}" }, + "offlineExport": { + "settings_label": "Exportovať uloženú poštu", + "settings_description": "Uložte poštu z tohto zariadenia ako súbory mbox alebo .eml. Funguje to aj offline.", + "title": "Exportovať uloženú poštu", + "description": "Uloží to, čo toto zariadenie uchováva, bajt po bajte. Pošta staršia ako zvolené obdobie v tom nie je. Ak chcete uložiť všetko zo servera, použite „Exportovať všetku poštu“.", + "folders_label": "Priečinky", + "all_folders": "Všetky priečinky", + "folder_kept": "{kept} z {total} tu", + "loading": "Čítanie údajov z tohto zariadenia…", + "load_failed": "Poštu na tomto zariadení sa nepodarilo prečítať.", + "nothing_kept": "Na tomto zariadení ešte nie je uložená žiadna pošta.", + "kept": "Originály na tomto zariadení: {kept} z {total} správ", + "missing_online": "Na stiahnutie zo servera: {count}", + "missing_offline": "Nedá sa exportovať offline: {count}. Môžete to skúsiť znova, keď budete online.", + "reason_attachments_off": "Pôvodné správy sa uchovávajú, len keď je zapnuté „Uchovávať prílohy“.", + "reason_not_kept": "Pôvodné správy sa sťahujú ako posledné a len vtedy, keď je na ne miesto.", + "not_kept_attachments_off": "Bez originálu, pretože toto zariadenie neuchováva prílohy: {count}", + "not_kept_not_kept": "Zatiaľ bez originálu na tomto zariadení: {count}", + "retry": "Skúsiť znova online", + "retry_online": "Ste online, takže môžu prísť zo servera.", + "retry_offline": "Môžete to skúsiť znova, keď budete online.", + "settings_button": "Exportovať…" + }, "deep_link": { "message_not_found": "Táto správa už nie je dostupná.", "thread_not_found": "Táto konverzácia už nie je dostupná.", diff --git a/locales/th/common.json b/locales/th/common.json index 7b92fca95..5bf4c56af 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2189,6 +2189,29 @@ "banner_refused": "ฉบับร่างที่เซิร์ฟเวอร์ปฏิเสธ: {count}", "banner_waiting": "ฉบับร่างที่รอเซิร์ฟเวอร์: {count}" }, + "offlineExport": { + "settings_label": "ส่งออกอีเมลที่เก็บไว้", + "settings_description": "บันทึกอีเมลในอุปกรณ์นี้เป็นไฟล์ mbox หรือ .eml ใช้งานแบบออฟไลน์ได้", + "title": "ส่งออกอีเมลที่เก็บไว้", + "description": "บันทึกสิ่งที่อุปกรณ์นี้เก็บไว้ตามต้นฉบับทุกไบต์ อีเมลที่เก่ากว่าช่วงเวลาที่คุณเลือกจะไม่รวมอยู่ด้วย หากต้องการบันทึกทุกอย่างจากเซิร์ฟเวอร์ ให้ใช้ “ส่งออกอีเมลทั้งหมด”", + "folders_label": "โฟลเดอร์", + "all_folders": "ทุกโฟลเดอร์", + "folder_kept": "ที่นี่ {kept} จาก {total}", + "loading": "กำลังอ่านข้อมูลในอุปกรณ์นี้…", + "load_failed": "อ่านอีเมลในอุปกรณ์นี้ไม่ได้", + "nothing_kept": "ยังไม่มีอีเมลที่เก็บไว้ในอุปกรณ์นี้", + "kept": "ต้นฉบับในอุปกรณ์นี้: {kept} จาก {total} ข้อความ", + "missing_online": "ต้องดาวน์โหลดจากเซิร์ฟเวอร์: {count}", + "missing_offline": "ส่งออกขณะออฟไลน์ไม่ได้: {count} ลองอีกครั้งได้เมื่อออนไลน์", + "reason_attachments_off": "ข้อความต้นฉบับจะเก็บไว้เฉพาะเมื่อเปิด “เก็บไฟล์แนบ”", + "reason_not_kept": "ข้อความต้นฉบับจะดาวน์โหลดเป็นลำดับสุดท้าย และเฉพาะเมื่อมีพื้นที่", + "not_kept_attachments_off": "ไม่มีต้นฉบับเพราะอุปกรณ์นี้ไม่เก็บไฟล์แนบ: {count}", + "not_kept_not_kept": "ยังไม่มีต้นฉบับในอุปกรณ์นี้: {count}", + "retry": "ลองอีกครั้งแบบออนไลน์", + "retry_online": "ตอนนี้คุณออนไลน์แล้ว จึงดึงจากเซิร์ฟเวอร์ได้", + "retry_offline": "ลองอีกครั้งได้เมื่อออนไลน์", + "settings_button": "ส่งออก…" + }, "plugin_pages": { "loading": "กำลังโหลดหน้าเว็บ...", "not_available_title": "หน้านี้ไม่พร้อมใช้งาน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 2517fb477..3c630b29f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Sunucunun reddettiği taslaklar: {count}", "banner_waiting": "Sunucuyu bekleyen taslaklar: {count}" }, + "offlineExport": { + "settings_label": "Saklanan postayı dışa aktar", + "settings_description": "Bu cihazdaki postayı mbox veya .eml dosyası olarak kaydedin. Çevrimdışı da çalışır.", + "title": "Saklanan postayı dışa aktar", + "description": "Bu cihazın sakladıklarını bayt bayt kaydeder. Seçtiğiniz süreden eski postalar buna dahil değildir. Sunucudaki her şeyi kaydetmek için “Tüm e-postaları dışa aktar” seçeneğini kullanın.", + "folders_label": "Klasörler", + "all_folders": "Tüm klasörler", + "folder_kept": "Burada: {kept} / {total}", + "loading": "Bu cihazdakiler okunuyor…", + "load_failed": "Bu cihazdaki posta okunamadı.", + "nothing_kept": "Bu cihazda henüz saklanan posta yok.", + "kept": "Bu cihazdaki orijinaller: {kept} / {total} ileti", + "missing_online": "Sunucudan indirilecek: {count}", + "missing_offline": "Çevrimdışıyken dışa aktarılamaz: {count}. Çevrimiçi olduğunuzda yeniden deneyebilirsiniz.", + "reason_attachments_off": "Orijinal iletiler yalnızca “Ekleri tut” açıkken saklanır.", + "reason_not_kept": "Orijinal iletiler en son indirilir, o da yalnızca yer varsa.", + "not_kept_attachments_off": "Bu cihazda ekler saklanmadığı için orijinal yok: {count}", + "not_kept_not_kept": "Bu cihazda henüz orijinal yok: {count}", + "retry": "Çevrimiçi yeniden dene", + "retry_online": "Artık çevrimiçisiniz, bunlar sunucudan gelebilir.", + "retry_offline": "Çevrimiçi olduğunuzda yeniden deneyebilirsiniz.", + "settings_button": "Dışa aktar…" + }, "deep_link": { "message_not_found": "Bu ileti artık kullanılamıyor.", "thread_not_found": "Bu sohbet artık kullanılamıyor.", diff --git a/locales/uk/common.json b/locales/uk/common.json index efc829880..fc7449ac4 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Чернетки, відхилені сервером: {count}", "banner_waiting": "Чернетки, що чекають на сервер: {count}" }, + "offlineExport": { + "settings_label": "Експорт збереженої пошти", + "settings_description": "Збережіть пошту з цього пристрою у файли mbox або .eml. Працює і без мережі.", + "title": "Експорт збереженої пошти", + "description": "Зберігає те, що зберігається на цьому пристрої, байт у байт. Пошти, старшої за вибраний період, тут немає. Щоб зберегти все із сервера, скористайтеся «Експортувати всю пошту».", + "folders_label": "Папки", + "all_folders": "Усі папки", + "folder_kept": "Тут: {kept} із {total}", + "loading": "Читання даних на цьому пристрої…", + "load_failed": "Не вдалося прочитати пошту на цьому пристрої.", + "nothing_kept": "На цьому пристрої ще немає збереженої пошти.", + "kept": "Оригінали на цьому пристрої: {kept} із {total}", + "missing_online": "Завантажити із сервера: {count}", + "missing_offline": "Не можна експортувати без мережі: {count}. Спробуйте ще раз, коли з'явиться мережа.", + "reason_attachments_off": "Початкові повідомлення зберігаються, лише якщо ввімкнено «Зберігати вкладення».", + "reason_not_kept": "Початкові повідомлення завантажуються останніми й лише поки є місце.", + "not_kept_attachments_off": "Немає оригіналу, бо на цьому пристрої не зберігаються вкладення: {count}", + "not_kept_not_kept": "Поки немає оригіналу на цьому пристрої: {count}", + "retry": "Повторити в мережі", + "retry_online": "Мережа є, тож їх можна отримати із сервера.", + "retry_offline": "Спробуйте ще раз, коли з'явиться мережа.", + "settings_button": "Експортувати…" + }, "deep_link": { "message_not_found": "Цей лист більше недоступний.", "thread_not_found": "Ця розмова більше недоступна.", diff --git a/locales/vi/common.json b/locales/vi/common.json index e57089754..729378a0f 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5856,6 +5856,29 @@ "banner_refused": "Bản nháp bị máy chủ từ chối: {count}", "banner_waiting": "Bản nháp đang chờ máy chủ: {count}" }, + "offlineExport": { + "settings_label": "Xuất thư đã lưu", + "settings_description": "Lưu thư trên thiết bị này thành tệp mbox hoặc .eml. Hoạt động cả khi ngoại tuyến.", + "title": "Xuất thư đã lưu", + "description": "Lưu đúng nguyên bản những gì thiết bị này giữ. Thư cũ hơn khoảng thời gian bạn chọn không có trong đó. Để lưu mọi thứ trên máy chủ, hãy dùng “Xuất toàn bộ thư”.", + "folders_label": "Thư mục", + "all_folders": "Tất cả thư mục", + "folder_kept": "Ở đây {kept}/{total}", + "loading": "Đang đọc dữ liệu trên thiết bị này…", + "load_failed": "Không đọc được thư trên thiết bị này.", + "nothing_kept": "Chưa có thư nào được lưu trên thiết bị này.", + "kept": "Bản gốc trên thiết bị này: {kept}/{total} thư", + "missing_online": "Cần tải từ máy chủ: {count}", + "missing_offline": "Không xuất được khi ngoại tuyến: {count}. Bạn có thể thử lại khi trực tuyến.", + "reason_attachments_off": "Thư gốc chỉ được giữ khi “Giữ tệp đính kèm” đang bật.", + "reason_not_kept": "Thư gốc được tải sau cùng và chỉ khi còn chỗ.", + "not_kept_attachments_off": "Không có bản gốc vì thiết bị này không giữ tệp đính kèm: {count}", + "not_kept_not_kept": "Chưa có bản gốc trên thiết bị này: {count}", + "retry": "Thử lại khi trực tuyến", + "retry_online": "Bạn đang trực tuyến nên có thể lấy từ máy chủ.", + "retry_offline": "Bạn có thể thử lại khi trực tuyến.", + "settings_button": "Xuất…" + }, "deep_link": { "message_not_found": "Thư này không còn nữa.", "thread_not_found": "Cuộc trò chuyện này không còn tồn tại nữa.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index bd69c19d3..ba92e0d83 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "伺服器拒絕的草稿:{count}", "banner_waiting": "等待伺服器的草稿:{count}" }, + "offlineExport": { + "settings_label": "匯出已保存的郵件", + "settings_description": "將此裝置上的郵件儲存為 mbox 或 .eml 檔案。離線時也可使用。", + "title": "匯出已保存的郵件", + "description": "依原樣儲存此裝置保留的內容。早於所選期間的郵件不包含在內。要儲存伺服器上的全部內容,請使用「匯出所有郵件」。", + "folders_label": "資料夾", + "all_folders": "所有資料夾", + "folder_kept": "此處 {kept} / {total}", + "loading": "正在讀取此裝置上的內容…", + "load_failed": "無法讀取此裝置上的郵件。", + "nothing_kept": "此裝置上尚未保存郵件。", + "kept": "此裝置上的原件:{kept} / {total} 封郵件", + "missing_online": "要從伺服器下載:{count}", + "missing_offline": "離線時無法匯出:{count}。連線後可以重試。", + "reason_attachments_off": "只有開啟「保留附件」時才會保留原始郵件。", + "reason_not_kept": "原始郵件最後下載,且僅在有空間時下載。", + "not_kept_attachments_off": "此裝置不保留附件,因此沒有原件:{count}", + "not_kept_not_kept": "此裝置上尚無原件:{count}", + "retry": "連線後重試", + "retry_online": "現在已連線,可以從伺服器取得。", + "retry_offline": "連線後可以重試。", + "settings_button": "匯出…" + }, "deep_link": { "message_not_found": "已無法使用此郵件。", "thread_not_found": "已無法使用此對話串。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4356828ad..d34fbe623 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "服务器拒绝的草稿:{count}", "banner_waiting": "等待服务器的草稿:{count}" }, + "offlineExport": { + "settings_label": "导出已保存的邮件", + "settings_description": "将此设备上的邮件保存为 mbox 或 .eml 文件。离线时也可使用。", + "title": "导出已保存的邮件", + "description": "按原样保存此设备保留的内容。早于所选时间范围的邮件不包含在内。要保存服务器上的全部内容,请使用“导出所有邮件”。", + "folders_label": "文件夹", + "all_folders": "所有文件夹", + "folder_kept": "此处 {kept} / {total}", + "loading": "正在读取此设备上的内容…", + "load_failed": "无法读取此设备上的邮件。", + "nothing_kept": "此设备上还没有保存邮件。", + "kept": "此设备上的原件:{kept} / {total} 封邮件", + "missing_online": "要从服务器下载:{count}", + "missing_offline": "离线时无法导出:{count}。联网后可以重试。", + "reason_attachments_off": "只有开启“保留附件”时才会保留原始邮件。", + "reason_not_kept": "原始邮件最后下载,且仅在有空间时下载。", + "not_kept_attachments_off": "此设备不保留附件,因此没有原件:{count}", + "not_kept_not_kept": "此设备上还没有原件:{count}", + "retry": "联网后重试", + "retry_online": "现在已联网,可以从服务器获取。", + "retry_offline": "联网后可以重试。", + "settings_button": "导出…" + }, "deep_link": { "message_not_found": "此邮件已不可用。", "thread_not_found": "此会话已不可用。", diff --git a/stores/mail-transfer-store.ts b/stores/mail-transfer-store.ts index e5ca8c792..79978fe50 100644 --- a/stores/mail-transfer-store.ts +++ b/stores/mail-transfer-store.ts @@ -22,7 +22,13 @@ export interface ExportRequest { mailboxId?: string; } -export type TransferRequest = ImportRequest | ExportRequest; +/** Mail kept on this device, of a signed-in account, not only the active one. */ +export interface LocalExportRequest { + kind: "local-export"; + account: string; +} + +export type TransferRequest = ImportRequest | ExportRequest | LocalExportRequest; interface JobBase { controller: AbortController; @@ -72,6 +78,7 @@ export const useMailTransferStore = create()((set, get) => ({ // The device policy "No export" of the active account (Organisation > // Devices): the dialog doesn't open and the app says why. Import stays. if (request.kind === "export" && !downloadAllowed("export", null)) return false; + if (request.kind === "local-export" && !downloadAllowed("export", request.account)) return false; if (isJobActive(get().job)) { set({ visible: true }); return false; @@ -103,6 +110,11 @@ export function openMailImport(request: Omit = {}): void useMailTransferStore.getState().open({ kind: "import", ...request }); } +/** Opens the export of what an account keeps on this device. */ +export function openLocalExport(account: string): void { + useMailTransferStore.getState().open({ kind: "local-export", account }); +} + /** Opens the export dialog for a folder, or for the whole account. */ export function openMailExport(request: Omit = {}): void { useMailTransferStore.getState().open({ kind: "export", ...request }); From e94219dafac0449fac1f3c142827c5263137d8aa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:42:19 +0100 Subject: [PATCH 31/45] fix: keep the example address out of the code the outgoing host guard reads --- lib/event-links.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/event-links.ts b/lib/event-links.ts index 5f1f8f12b..9c4c1f867 100644 --- a/lib/event-links.ts +++ b/lib/event-links.ts @@ -122,10 +122,9 @@ export interface JoinLink { } /** - * The address to offer as Join: web links only, with no user name in them, since - * `https://meet.google.com@evil.example` goes to evil.example. An app scheme can start - * anything on the machine (ms-msdt:, search-ms:, smb:), and this comes from whoever sent - * the invitation. + * The address to offer as Join: web links only, with no user name in them, since what + * comes before an @ only looks like the host. An app scheme can start anything on the + * machine (ms-msdt:, search-ms:, smb:), and this comes from whoever sent the invitation. */ export function joinLinkFrom(uri: string): JoinLink | null { let parsed: URL; From 03cdf69bf2541f3f2abd564e08f5ef33bdf09b58 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:55:17 +0100 Subject: [PATCH 32/45] feat: let desktop users opt in to usage info and crash reports, each with its own switch --- .../desktop-telemetry-settings.test.tsx | 168 ++++++++++ .../settings/desktop-telemetry-settings.tsx | 173 +++++++++++ components/settings/general-settings.tsx | 2 + instrumentation.node.ts | 3 + lib/__tests__/telemetry-desktop.test.ts | 293 ++++++++++++++++++ lib/admin/outgoing-hosts-registry.ts | 2 +- .../__tests__/desktop-telemetry.test.ts | 145 +++++++++ lib/desktop/desktop-telemetry.ts | 155 +++++++++ lib/desktop/policy-schema.ts | 4 +- lib/desktop/server-links.ts | 33 ++ lib/desktop/server-privacy.ts | 19 ++ lib/desktop/types.ts | 38 +++ .../__tests__/desktop-main-errors.test.ts | 132 ++++++++ .../__tests__/desktop-reports.test.ts | 141 +++++++++ lib/error-reporting/client.ts | 19 +- lib/error-reporting/envelope.ts | 10 +- lib/error-reporting/scrub.ts | 7 +- lib/error-reporting/server.ts | 28 +- lib/error-reporting/settings.ts | 7 +- lib/error-reporting/stack.ts | 4 +- lib/telemetry/payload.ts | 9 + lib/telemetry/sender.ts | 14 +- lib/telemetry/state.ts | 7 + lib/telemetry/types.ts | 2 +- locales/ar/common.json | 25 ++ locales/ca/common.json | 25 ++ locales/cs/common.json | 25 ++ locales/da/common.json | 25 ++ locales/de/common.json | 25 ++ locales/en/common.json | 25 ++ locales/es/common.json | 25 ++ locales/et/common.json | 25 ++ locales/fa/common.json | 25 ++ locales/fr/common.json | 25 ++ locales/he/common.json | 25 ++ locales/hu/common.json | 25 ++ locales/it/common.json | 25 ++ locales/ja/common.json | 25 ++ locales/ko/common.json | 25 ++ locales/lv/common.json | 25 ++ locales/mn/common.json | 25 ++ locales/nb/common.json | 25 ++ locales/nl/common.json | 25 ++ locales/pl/common.json | 25 ++ locales/pt/common.json | 25 ++ locales/ro/common.json | 25 ++ locales/ru/common.json | 25 ++ locales/sk/common.json | 25 ++ locales/th/common.json | 25 ++ locales/tr/common.json | 25 ++ locales/uk/common.json | 25 ++ locales/vi/common.json | 25 ++ locales/zh-TW/common.json | 25 ++ locales/zh/common.json | 25 ++ 54 files changed, 2150 insertions(+), 15 deletions(-) create mode 100644 components/settings/__tests__/desktop-telemetry-settings.test.tsx create mode 100644 components/settings/desktop-telemetry-settings.tsx create mode 100644 lib/__tests__/telemetry-desktop.test.ts create mode 100644 lib/desktop/__tests__/desktop-telemetry.test.ts create mode 100644 lib/desktop/desktop-telemetry.ts create mode 100644 lib/desktop/server-links.ts create mode 100644 lib/desktop/server-privacy.ts create mode 100644 lib/error-reporting/__tests__/desktop-main-errors.test.ts create mode 100644 lib/error-reporting/__tests__/desktop-reports.test.ts diff --git a/components/settings/__tests__/desktop-telemetry-settings.test.tsx b/components/settings/__tests__/desktop-telemetry-settings.test.tsx new file mode 100644 index 000000000..ff05ba82c --- /dev/null +++ b/components/settings/__tests__/desktop-telemetry-settings.test.tsx @@ -0,0 +1,168 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from '@/lib/desktop/types'; +import { DesktopTelemetrySettings } from '../desktop-telemetry-settings'; + +// The desktop app's two switches. A browser sees nothing. The shell decides and asks; this +// page only shows what it says, and sends a crash file only when the button is pressed. + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); +vi.mock('next-intl', async (importOriginal) => ({ + ...(await importOriginal()), + useTranslations: () => (key: string) => key, + useFormatter: () => ({ dateTime: (date: Date) => date.toISOString() }), +})); +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const OFF: DesktopPrivacyState = { + telemetry: { on: false, forcedOff: false }, + crashReports: { on: false, forcedOff: false, uploadUrl: '' }, +}; +const CRASH_ON: DesktopPrivacyState = { ...OFF, crashReports: { on: true, forcedOff: false, uploadUrl: '' } }; +const FILE: DesktopCrashFile = { id: 'a1', createdAt: Date.parse('2026-11-03T10:00:00Z'), bytes: 2048 }; + +function bridge(state: DesktopPrivacyState, crashes: DesktopCrashFile[] = [], mode: 'standalone' | 'instance' = 'standalone') { + const privacy = { + get: vi.fn(async () => state), + setTelemetry: vi.fn(async (on: boolean) => ({ ...state, telemetry: { ...state.telemetry, on } })), + setCrashReports: vi.fn(async (on: boolean) => ({ ...state, crashReports: { ...state.crashReports, on } })), + setCrashUploadUrl: vi.fn(async (_url: string) => ({ ok: true, state })), + listCrashes: vi.fn(async () => crashes), + sendCrash: vi.fn(async (_id: string): Promise => ({ ok: true })), + deleteCrashes: vi.fn(async () => {}), + onChange: vi.fn(() => () => {}), + }; + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode, privacy }; + return privacy; +} + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + toast.success.mockClear(); + toast.error.mockClear(); +}); + +const checked = (id: string) => screen.getByTestId(id).getAttribute('aria-checked'); + +describe('usage info and crash reports', () => { + it('shows nothing in a browser', async () => { + const { container } = render(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(container.textContent).toBe(''); + }); + + it('shows nothing under a shell without the bridge member', async () => { + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' }; + const { container } = render(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(container.textContent).toBe(''); + }); + + it('starts with both off, and says what a crash file can hold', async () => { + bridge(OFF); + render(); + await screen.findByTestId('desktop-telemetry-toggle'); + expect(checked('desktop-telemetry-toggle')).toBe('false'); + expect(checked('desktop-crash-toggle')).toBe('false'); + expect(screen.getByText('crash_memory')).toBeTruthy(); + }); + + it('asks the shell and shows its answer', async () => { + const privacy = bridge(OFF); + render(); + fireEvent.click(await screen.findByTestId('desktop-telemetry-toggle')); + await waitFor(() => expect(privacy.setTelemetry).toHaveBeenCalledWith(true)); + await waitFor(() => expect(checked('desktop-telemetry-toggle')).toBe('true')); + expect(privacy.setCrashReports).not.toHaveBeenCalled(); + }); + + it('shows a switch still off when the user said no in the shell\'s dialog', async () => { + const privacy = bridge(OFF); + privacy.setCrashReports.mockResolvedValue(OFF); + render(); + fireEvent.click(await screen.findByTestId('desktop-crash-toggle')); + await waitFor(() => expect(privacy.setCrashReports).toHaveBeenCalledWith(true)); + expect(checked('desktop-crash-toggle')).toBe('false'); + }); + + it('locks both when a policy forces them off, and says who did', async () => { + const privacy = bridge({ + telemetry: { on: false, forcedOff: true }, + crashReports: { on: false, forcedOff: true, uploadUrl: '' }, + }); + render(); + const toggle = await screen.findByTestId('desktop-telemetry-toggle'); + expect(toggle.hasAttribute('disabled')).toBe(true); + expect(screen.getByTestId('desktop-crash-toggle').hasAttribute('disabled')).toBe(true); + expect(screen.getAllByText('forced_off')).toHaveLength(2); + fireEvent.click(toggle); + expect(privacy.setTelemetry).not.toHaveBeenCalled(); + }); + + it('adds the note about the instance\'s own admin only on an instance', async () => { + bridge(OFF, [], 'instance'); + render(); + await screen.findByTestId('desktop-telemetry-toggle'); + expect(screen.getByText(/instance_note/)).toBeTruthy(); + }); +}); + +describe('crash files', () => { + it('sends only when the button is pressed', async () => { + const privacy = bridge(CRASH_ON, [FILE]); + render(); + const send = await screen.findByTestId('desktop-crash-send'); + expect(privacy.sendCrash).not.toHaveBeenCalled(); + fireEvent.click(send); + await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalledWith('a1')); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('sent')); + }); + + it('says so when no address is set', async () => { + const privacy = bridge(CRASH_ON, [FILE]); + privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'no-url' }); + render(); + fireEvent.click(await screen.findByTestId('desktop-crash-send')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('send_no_url')); + }); + + it('says nothing when the user said no in the dialog', async () => { + const privacy = bridge(CRASH_ON, [FILE]); + privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'cancelled' }); + render(); + fireEvent.click(await screen.findByTestId('desktop-crash-send')); + await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(toast.error).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('asks for the address only with crash reports on, and shows a bad one\'s answer', async () => { + const privacy = bridge(CRASH_ON); + privacy.setCrashUploadUrl.mockResolvedValue({ ok: false, state: CRASH_ON }); + render(); + const input = await screen.findByTestId('desktop-crash-url'); + fireEvent.change(input, { target: { value: 'ftp://nope' } }); + fireEvent.click(screen.getByRole('button', { name: 'url_save' })); + await waitFor(() => expect(privacy.setCrashUploadUrl).toHaveBeenCalledWith('ftp://nope')); + await screen.findByText('url_invalid'); + }); + + it('hides the address with crash reports off', async () => { + bridge(OFF); + render(); + await screen.findByTestId('desktop-telemetry-toggle'); + expect(screen.queryByTestId('desktop-crash-url')).toBeNull(); + }); + + it('deletes through the shell', async () => { + const privacy = bridge(CRASH_ON, [FILE]); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'delete_all' })); + await waitFor(() => expect(privacy.deleteCrashes).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/components/settings/desktop-telemetry-settings.tsx b/components/settings/desktop-telemetry-settings.tsx new file mode 100644 index 000000000..cc2dda5f3 --- /dev/null +++ b/components/settings/desktop-telemetry-settings.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useFormatter, useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from "@/lib/desktop/types"; +import { toast } from "@/stores/toast-store"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +const SEND_ERROR: Record["reason"], string | null> = { + "no-consent": "send_no_consent", + "no-url": "send_no_url", + "not-found": "send_failed", + failed: "send_failed", + // The user said no in the dialog: nothing to tell them. + cancelled: null, +}; + +/** + * Usage info and crash reports in the desktop app: each with its own switch, both off + * until the user says yes, and only where there's an app. A browser never sees this, and + * it renders nothing until the bridge has answered. The shell does the deciding, the + * asking and the sending. + */ +export function DesktopTelemetrySettings() { + const t = useTranslations("desktopTelemetry"); + const format = useFormatter(); + const bridge = getDesktopBridge(); + const privacy = bridge?.privacy; + const [state, setState] = useState(null); + const [crashes, setCrashes] = useState([]); + const [url, setUrl] = useState(""); + const [urlInvalid, setUrlInvalid] = useState(false); + const [sending, setSending] = useState(null); + + const refreshCrashes = useCallback(() => { + void privacy?.listCrashes().then(setCrashes, () => {}); + }, [privacy]); + + useEffect(() => { + if (!privacy) return; + let stale = false; + const apply = (next: DesktopPrivacyState) => { + if (stale) return; + setState(next); + setUrl(next.crashReports.uploadUrl); + }; + void privacy.get().then(apply, () => {}); + refreshCrashes(); + const stop = privacy.onChange((next) => { + apply(next); + refreshCrashes(); + }); + return () => { + stale = true; + stop(); + }; + }, [privacy, refreshCrashes]); + + if (!privacy || !state) return null; + const { telemetry, crashReports } = state; + + const saveUrl = async () => { + const result = await privacy.setCrashUploadUrl(url.trim()); + setUrlInvalid(!result.ok); + if (result.ok) setState(result.state); + }; + + const send = async (id: string) => { + setSending(id); + try { + const result = await privacy.sendCrash(id); + if (result.ok) toast.success(t("sent")); + else if (SEND_ERROR[result.reason]) toast.error(t(SEND_ERROR[result.reason]!)); + } finally { + setSending(null); + } + }; + + const showCrashFiles = crashReports.on || crashes.length > 0; + + return ( + + + void privacy.setTelemetry(on).then(setState)} + /> + + + void privacy.setCrashReports(on).then((next) => { + setState(next); + refreshCrashes(); + })} + /> + + {!crashReports.forcedOff && ( +

+ {t("crash_memory")} + {bridge?.mode === "instance" && ` ${t("instance_note")}`} +

+ )} + {crashReports.on && ( + +
+ { + setUrl(event.target.value); + setUrlInvalid(false); + }} + className="w-64" + /> + +
+
+ )} + {showCrashFiles && ( + + {crashes.length > 0 && ( + + )} + + )} + {showCrashFiles && crashes.length === 0 && ( +

{t("files_empty")}

+ )} + {crashes.map((crash) => ( + + + + ))} +
+ ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 0a03272bf..c61d593f4 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -5,6 +5,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { LanguageSettings } from './language-settings'; import { DesktopKeystoreSettings } from './desktop-keystore-settings'; +import { DesktopTelemetrySettings } from './desktop-telemetry-settings'; import { ProtocolHandlerSettings } from './protocol-handler-settings'; import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; @@ -27,6 +28,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea {!IS_LITE && } + diff --git a/instrumentation.node.ts b/instrumentation.node.ts index a1f36ff11..971184ac8 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -134,6 +134,9 @@ migrateLegacyAdminLayout() { name: "telemetry-errors", start: instantErrors.startInstantErrors, stop: instantErrors.stopInstantErrors }, { name: "version-check", start: versionCheck.startScheduler, stop: versionCheck.stopScheduler }, ]); + if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1") { + (await import("./lib/desktop/server-links")).installDesktopServerLinks(); + } }) .catch((err) => { logger.warn("Admin dashboard init skipped", { error: err instanceof Error ? err.message : String(err) }); diff --git a/lib/__tests__/telemetry-desktop.test.ts b/lib/__tests__/telemetry-desktop.test.ts new file mode 100644 index 000000000..d177337f9 --- /dev/null +++ b/lib/__tests__/telemetry-desktop.test.ts @@ -0,0 +1,293 @@ +// @vitest-environment node +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } 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 { pathToFileURL } from 'node:url'; +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'; + +const DASHBOARD = process.env.BULWARK_DASHBOARD_DIR ?? 'C:/Users/info/Downloads/jmap-webmail/repos/dashboard'; +const VALIDATE = path.join(DASHBOARD, 'telemetry-collector/src/validate.ts'); +const collector = existsSync(VALIDATE) + ? (await import(/* @vite-ignore */ pathToFileURL(VALIDATE).href) as { + validate(input: unknown): { schema: string; build: string | null; platform: string; version: string } | null; + validateErrorBatch(input: unknown): { errors: { version: string; frames: unknown[] }[] } | null; + }) + : null; + +interface Received { + path: string; + body: Record; +} + +let server: Server; +let received: Received[] = []; +let base = ''; +let dir = ''; + +const PRIVACY = Symbol.for('bulwark.desktop.privacy'); +const TRUSTED = Symbol.for('bulwark.desktop.trustedJmapOrigins'); +const holder = globalThis as unknown as Record; + +function shellSays(privacy: { telemetry: boolean; crashReports: boolean } | null): void { + holder[PRIVACY] = privacy ? { ...privacy, appVersion: '0.3.0', build: 'desktop.standalone.beta' } : 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(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +beforeEach(async () => { + flag.IS_DESKTOP_BUILD = true; + received = []; + dir = mkdtempSync(path.join(tmpdir(), 'bw-telemetry-desktop-')); + vi.stubEnv('TELEMETRY_DATA_DIR', dir); + vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', '1'); + // What the shell's profile sets: the environment plays no part either way. + vi.stubEnv('BULWARK_TELEMETRY', 'off'); + vi.stubEnv('BULWARK_TELEMETRY_ERRORS', ''); + shellSays(null); + resetAggregateForTests(); + resetInstantErrorsForTests(); + await saveState({ consent: 'off', endpoint: `${base}/v1/heartbeat`, 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'); + // `unknown` until the collector patch is deployed, then `desktop`. + expect(['unknown', 'desktop']).toContain(clean.platform); + // 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('after a restart with a slot in the past, a heartbeat goes within a minute, not an hour', async () => { + shellSays({ telemetry: true, crashReports: false }); + await saveState({ ...(await loadState()), nextScheduledAt: new Date(Date.now() - 3 * 86_400_000).toISOString() }); + await startScheduler(); + expect(await nextIn()).toBeLessThanOrEqual(60_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(60_000); + }); + + 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()), 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('crash reports have their own consent', () => { + const mainFrame = { path: '/desktop/main.mjs', line: 4, column: 2 }; + const bundleFrame = { path: '/.next/server/chunks/1.js', line: 9, column: 1 }; + + it('collects and sends error counts with telemetry off, and no heartbeat', async () => { + shellSays({ telemetry: false, crashReports: true }); + expect(await shouldCollectErrors()).toBe(true); + recordError({ name: 'TypeError', frames: [bundleFrame] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + expect(received.map((r) => r.path)).toEqual(['/v1/errors']); + expect((await sendOnce()).ok).toBe(false); + expect(received).toHaveLength(1); + }); + + it('telemetry alone collects no errors', async () => { + shellSays({ telemetry: true, crashReports: false }); + expect(await shouldCollectErrors()).toBe(false); + recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + expect(received).toEqual([]); + }); + + it.skipIf(!collector)('a batch with a main-process error is what the real collector accepts', async () => { + shellSays({ telemetry: false, crashReports: true }); + recordError({ name: 'TypeError', frames: [mainFrame, bundleFrame] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + const clean = collector!.validateErrorBatch(received[0].body)!; + expect(clean).not.toBeNull(); + expect(clean.errors[0].version).toBe('1.20.0+desktop'); + // The unpatched collector refuses a whole batch over a frame outside the bundles. + expect(clean.errors[0].frames).toEqual([expect.objectContaining({ path: '/.next/server/chunks/1.js' })]); + }); + + it('keeps main-process frames in what stays on this computer', async () => { + shellSays({ telemetry: false, crashReports: true }); + recordError({ name: 'TypeError', frames: [mainFrame] }, 'server', '1.20.0+desktop'); + await flushErrors(); + const stored = JSON.parse(readFileSync(path.join(dir, 'errors.json'), 'utf8')) as Record>; + const entry = Object.values(Object.values(stored)[0])[0]; + expect(entry.frames[0].path).toBe('/desktop/main.mjs'); + }); +}); + +describe('the endpoint guard', () => { + it('still refuses a private address when the shell trusts it as a mail server', async () => { + vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', ''); + holder[TRUSTED] = new Set([base, 'http://192.168.1.5']); + await expect(fetchTelemetryTarget(`${base}/v1/heartbeat`, { method: 'POST', body: '{}' })).rejects.toThrow(); + await expect(fetchTelemetryTarget('http://192.168.1.5/v1/heartbeat', { method: 'POST', body: '{}' })).rejects.toThrow(); + expect(received).toEqual([]); + }); + + it('a stored endpoint on a private address goes nowhere with consent', async () => { + vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', ''); + shellSays({ telemetry: true, crashReports: false }); + holder[TRUSTED] = new Set([base]); + const result = await sendOnce(); + expect(result.ok).toBe(false); + expect(result.error).toContain('endpoint blocked'); + expect(received).toEqual([]); + }); +}); + +describe('a server build', () => { + it('ignores what a shell would have said, so the admin\'s consent stands', async () => { + flag.IS_DESKTOP_BUILD = false; + shellSays({ telemetry: true, crashReports: true }); + vi.stubEnv('BULWARK_TELEMETRY', ''); + expect((await sendOnce()).ok).toBe(false); + expect(received).toEqual([]); + expect(await shouldCollectErrors()).toBe(false); + await saveState({ ...(await loadState()), consent: 'on' }); + 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 here. +it('finds the collector on this machine', () => { + if (process.env.CI) return; + writeFileSync(path.join(dir, 'probe'), ''); + expect(existsSync(VALIDATE)).toBe(true); +}); diff --git a/lib/admin/outgoing-hosts-registry.ts b/lib/admin/outgoing-hosts-registry.ts index 053a8df61..f27737bbd 100644 --- a/lib/admin/outgoing-hosts-registry.ts +++ b/lib/admin/outgoing-hosts-registry.ts @@ -39,7 +39,7 @@ 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.', + usedBy: 'Anonymous daily heartbeat, sent only after an admin, or the desktop app\'s user, 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).', inMode: 'Never sent, whatever the telemetry setting says.', lite: false, diff --git a/lib/desktop/__tests__/desktop-telemetry.test.ts b/lib/desktop/__tests__/desktop-telemetry.test.ts new file mode 100644 index 000000000..1d57209c1 --- /dev/null +++ b/lib/desktop/__tests__/desktop-telemetry.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment node +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + DESKTOP_CATCH_UP_MS, + DESKTOP_HEARTBEAT_INTERVAL_MS, + buildDesktopErrorBatch, + buildDesktopPing, + consentInForce, + desktopBuildTag, + desktopChannel, + desktopOsFamily, + forcedOffByPolicy, + heartbeatDelayMs, +} from '../desktop-telemetry'; + +const DASHBOARD = process.env.BULWARK_DASHBOARD_DIR ?? 'C:/Users/info/Downloads/jmap-webmail/repos/dashboard'; +const VALIDATE = path.join(DASHBOARD, 'telemetry-collector/src/validate.ts'); +const collector = existsSync(VALIDATE) + ? (await import(/* @vite-ignore */ pathToFileURL(VALIDATE).href) as { + validate(input: unknown): Record | null; + validateErrorBatch(input: unknown): Record | null; + }) + : null; + +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'); + + it('soon, with none sent yet or the last one long ago', () => { + expect(heartbeatDelayMs(undefined, now)).toBe(DESKTOP_CATCH_UP_MS); + expect(heartbeatDelayMs(null, now)).toBe(DESKTOP_CATCH_UP_MS); + expect(heartbeatDelayMs(now - 3 * DESKTOP_HEARTBEAT_INTERVAL_MS, now)).toBe(DESKTOP_CATCH_UP_MS); + }); + + it('a day after the last, and not sooner', () => { + expect(heartbeatDelayMs(now - 3_600_000, now)).toBe(DESKTOP_HEARTBEAT_INTERVAL_MS - 3_600_000); + }); + + it('soon again if the clock went back', () => { + expect(heartbeatDelayMs(now + 3_600_000, now)).toBe(DESKTOP_CATCH_UP_MS); + }); +}); + +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(['unknown', 'desktop']).toContain(clean.platform); + // 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(clean.errors).toEqual([expect.objectContaining({ name: 'TypeError', count: '6-10', version: '0.3.0+desktop', days: 1, frames: [] })]); + }); + + it('fits the collector\'s pattern for build on every mode and channel', () => { + for (const mode of ['standalone', 'instance'] as const) { + for (const channel of ['stable', 'beta'] as const) { + expect(desktopBuildTag(mode, channel)).toMatch(/^[\w.+-]{1,64}$/); + } + } + }); + + it('names the channel: forced, then the user, then the version', () => { + expect(desktopChannel({ value: 'stable', locked: true }, 'beta', '1.0.0-beta.1')).toBe('stable'); + expect(desktopChannel({ value: 'stable', locked: false }, 'beta', '1.0.0')).toBe('beta'); + expect(desktopChannel({ value: 'stable', locked: false }, undefined, '1.0.0-beta.1')).toBe('beta'); + expect(desktopChannel({ value: 'stable', locked: false }, 'nonsense', '1.0.0')).toBe('stable'); + }); + + it('names the OS the way the collector does', () => { + expect(desktopOsFamily('win32')).toBe('windows'); + expect(desktopOsFamily('darwin')).toBe('darwin'); + expect(desktopOsFamily('linux')).toBe('linux'); + expect(desktopOsFamily('freebsd')).toBe('unknown'); + }); + + it('holds no host name, so only one place has the collector', () => { + const source = readFileSync(path.join(__dirname, '..', 'desktop-telemetry.ts'), 'utf8'); + expect(source).not.toContain('bulwarkmail.org'); + }); + + it('the policy says which shell file acts on the two keys', async () => { + const { POLICY_CONSUMERS } = await import('../policy-schema'); + expect(POLICY_CONSUMERS.TelemetryAllowed).toEqual({ repo: 'shell', file: 'src/main/features/privacy.ts' }); + expect(POLICY_CONSUMERS.CrashReportsAllowed).toEqual({ repo: 'shell', file: 'src/main/features/privacy.ts' }); + }); +}); diff --git a/lib/desktop/desktop-telemetry.ts b/lib/desktop/desktop-telemetry.ts new file mode 100644 index 000000000..a61a3666a --- /dev/null +++ b/lib/desktop/desktop-telemetry.ts @@ -0,0 +1,155 @@ +/** + * What the desktop app's telemetry and crash reports agree on across the webmail and + * the shell: when consent counts, what a heartbeat from the shell holds, and when one + * is due. + * + * No DOM, no Node, no imports: the desktop repo keeps a byte-for-byte copy in + * src/shared/desktop-telemetry.ts. Main can't import lib/telemetry, which needs the + * store and node:dns, and in instance mode no server runs inside the app. It holds no + * host name either: the shell has its own constant, and lib/telemetry/types.ts has this + * side's. + * + * The collector decides the shape (repos/dashboard/telemetry-collector/src/validate.ts). + * Schema 1 drops fields it doesn't know and schema 2 refuses the whole heartbeat over + * one, so mode and channel travel in `build`, the one free field it keeps. It stores + * `platform: 'desktop'` as `unknown` until it has been patched. + */ + +export type DesktopTelemetryMode = 'standalone' | 'instance'; +export type DesktopTelemetryChannel = 'stable' | 'beta'; +export type DesktopOsFamily = 'linux' | 'darwin' | 'windows' | 'unknown'; + +/** What main tells the embedded server, by message. Nothing in a page can write it. */ +export interface DesktopServerPrivacy { + telemetry: boolean; + crashReports: boolean; + /** The desktop app's version. The webmail's own is in `NEXT_PUBLIC_APP_VERSION`. */ + appVersion: string; + /** `desktop..`. */ + build: string; +} + +/** + * 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. + */ +export const DESKTOP_CATCH_UP_MS = 30_000; +/** 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): number { + if (!lastSentAt || lastSentAt > now) return DESKTOP_CATCH_UP_MS; + return Math.max(DESKTOP_CATCH_UP_MS, lastSentAt + DESKTOP_HEARTBEAT_INTERVAL_MS - now); +} + +export interface DesktopPingInput { + /** The shell's own UUID, never an instance's. */ + instanceId: string; + appVersion: string; + nodeVersion: string; + osFamily: DesktopOsFamily; + mode: DesktopTelemetryMode; + channel: DesktopTelemetryChannel; + now: number; +} + +/** + * The shell's own heartbeat, for when no server runs inside the app. Features and counts + * are the instance's to report, so these are all empty and nothing is counted twice. + */ +export function buildDesktopPing(input: DesktopPingInput) { + return { + schema: '1' as const, + instance_id: input.instanceId, + ts: new Date(input.now).toISOString(), + version: input.appVersion, + build: desktopBuildTag(input.mode, input.channel), + platform: 'desktop', + node_version: input.nodeVersion, + os_family: input.osFamily, + stalwart_version: null, + features: { calendar: false, contacts: false, files: false, extensions: false, oauth_enabled: false, smime_enabled: false }, + counts: { accounts: '0', accounts_active_7d: '0', extensions_installed: 0, themes_installed: 0 }, + uptime_days: 0, + }; +} + +export function desktopErrorCountBucket(count: number): string { + if (count <= 1) return '1'; + if (count <= 5) return '2-5'; + if (count <= 10) return '6-10'; + if (count <= 50) return '11-50'; + if (count <= 200) return '51-200'; + return '201+'; +} + +export interface DesktopMainError { + name: string; + /** 16 hex digits. */ + fingerprint: string; + version: string; + count: number; +} + +/** + * Main's errors when no server runs inside the app: the collector's /v1/errors takes + * this without a heartbeat, so it works with telemetry off. Name and count only. The + * collector refuses a whole batch over a frame outside the webmail's bundles. + */ +export function buildDesktopErrorBatch(instanceId: string, now: number, errors: readonly DesktopMainError[]) { + return { + schema: '1' as const, + instance_id: instanceId, + day: new Date(now).toISOString().slice(0, 10), + errors: errors.map((error) => ({ + name: error.name, + fingerprint: error.fingerprint, + frames: [], + version: error.version, + count: desktopErrorCountBucket(error.count), + days: 1, + })), + }; +} diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index 3f45d884b..aa62fa720 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -823,8 +823,8 @@ export const POLICY_CONSUMERS: Record = { UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, AppLockRequired: { pendingConsumer: 'step 7, app lock' }, AppLockTimeoutMinutes: { pendingConsumer: 'step 7, app lock' }, - 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: { pendingConsumer: 'feat/native-helper: setKeystoreAllowedOrigins in src/main/features/keystore.ts' }, diff --git a/lib/desktop/server-links.ts b/lib/desktop/server-links.ts new file mode 100644 index 000000000..25257222b --- /dev/null +++ b/lib/desktop/server-links.ts @@ -0,0 +1,33 @@ +/** + * The two things main hands the embedded server while it runs. The server's host + * (src/server/host.ts in the desktop repo) has no way into Next's bundles but globalThis, + * so it calls what this leaves there. instrumentation.node.ts installs them, only in the + * desktop build. + */ + +import { captureDesktopMainError, type DesktopMainErrorInput } from '@/lib/error-reporting/server'; +import { reschedule } from '@/lib/telemetry/sender'; + +const PRIVACY_CHANGED = Symbol.for('bulwark.desktop.privacyChanged'); +const MAIN_ERRORS = Symbol.for('bulwark.desktop.mainErrors'); +const MAIN_ERRORS_READY = Symbol.for('bulwark.desktop.mainErrorsReady'); + +type Holder = Record; + +function drainMainErrors(holder: Holder): void { + const queue = holder[MAIN_ERRORS]; + if (!Array.isArray(queue)) return; + for (const error of queue.splice(0)) void captureDesktopMainError(error as DesktopMainErrorInput); +} + +export function installDesktopServerLinks(): void { + const holder = globalThis as unknown as Holder; + // Turning telemetry on starts the heartbeat now, without a restart. Off needs no + // more than the value: sendOnce reads it before every send. + holder[PRIVACY_CHANGED] = () => { + void reschedule(); + }; + // Errors from before this ran are waiting in the queue. + holder[MAIN_ERRORS_READY] = () => drainMainErrors(holder); + drainMainErrors(holder); +} diff --git a/lib/desktop/server-privacy.ts b/lib/desktop/server-privacy.ts new file mode 100644 index 000000000..df79316bb --- /dev/null +++ b/lib/desktop/server-privacy.ts @@ -0,0 +1,19 @@ +/** + * The desktop user's telemetry and crash report consent, as the embedded server sees it. + * + * The Electron main process sends this at start and on every change (src/server/host.ts + * in the desktop repo sets it on globalThis, which all of Next's bundles share, like the + * list of trusted servers). Nothing in the renderer or the environment can write it. + * + * Only the desktop build reads it. No value means nobody has said yes: everything off. + */ + +import { IS_DESKTOP_BUILD } from './flag'; +import type { DesktopServerPrivacy } from './desktop-telemetry'; + +const PRIVACY_KEY = Symbol.for('bulwark.desktop.privacy'); + +export function desktopServerPrivacy(): DesktopServerPrivacy | null { + if (!IS_DESKTOP_BUILD) return null; + return (globalThis as unknown as Record)[PRIVACY_KEY] ?? null; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 83773d92a..a032a0a72 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -192,6 +192,43 @@ export interface DesktopPolicyBridge { onChange: (listener: (snapshot: PolicySnapshot) => void) => () => void; } +export interface DesktopPrivacyState { + /** `forcedOff`: a policy says no, so `on` is false and the user can't change it. */ + telemetry: { on: boolean; forcedOff: boolean }; + /** `uploadUrl` is where "Send crash report" goes. Empty: nowhere, and the action says so. */ + crashReports: { on: boolean; forcedOff: boolean; uploadUrl: string }; +} + +export interface DesktopCrashFile { + /** Opaque: the only values `sendCrash` accepts. */ + id: string; + /** Milliseconds since 1970. */ + createdAt: number; + bytes: number; +} + +export type DesktopCrashSendResult = + | { ok: true } + | { ok: false; reason: 'no-consent' | 'no-url' | 'not-found' | 'cancelled' | 'failed' }; + +/** + * Telemetry and crash reports, each with its own consent, both off until the user says + * yes. A same-origin plugin can reach this, so saying yes and sending a crash file both + * ask in a native dialog that names where it goes. + */ +export interface DesktopPrivacyBridge { + get: () => Promise; + setTelemetry: (on: boolean) => Promise; + setCrashReports: (on: boolean) => Promise; + /** https, or http on this computer. Empty clears it. */ + setCrashUploadUrl: (url: string) => Promise<{ ok: boolean; state: DesktopPrivacyState }>; + listCrashes: () => Promise; + sendCrash: (id: string) => Promise; + deleteCrashes: () => Promise; + /** Also fires when a policy changes. Returns the unsubscribe. */ + onChange: (listener: (state: DesktopPrivacyState) => void) => () => void; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -206,6 +243,7 @@ export interface BulwarkDesktopBridge { auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; keystore?: DesktopKeystoreBridge; + privacy?: DesktopPrivacyBridge; } declare global { diff --git a/lib/error-reporting/__tests__/desktop-main-errors.test.ts b/lib/error-reporting/__tests__/desktop-main-errors.test.ts new file mode 100644 index 000000000..2afe4082c --- /dev/null +++ b/lib/error-reporting/__tests__/desktop-main-errors.test.ts @@ -0,0 +1,132 @@ +// @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 +// and, with a DSN, sends them tagged. 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'; +import { browserErrorSettings } from '../settings'; + +const MAIN = { + name: 'TypeError', + message: "ENOENT: open 'C:\\Users\\linus\\AppData\\Roaming\\Bulwark\\inbox.json' for ceo.private@acme-corp.example", + stack: 'TypeError: secret\n at sendPing (/desktop/main.mjs:120:7)\n at file:///C:/Users/linus/elsewhere/other.js:1:1', + mechanism: 'uncaughtexception', +}; + +let fetchSpy: ReturnType; + +beforeEach(() => { + resetAggregateForTests(); + // The server's limiter sends one per fingerprint per hour. + delete (globalThis as unknown as Record)[Symbol.for('bulwark.error-reporting.server')]; + consent.value = 'instant'; + flag.IS_DESKTOP_BUILD = true; + flag.dsn = ''; + fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +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('reaches a DSN tagged as desktop, in app, with nothing off the disk', async () => { + flag.dsn = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3'; + await captureDesktopMainError(MAIN); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const body = String(fetchSpy.mock.calls[0][1].body); + const event = JSON.parse(body.trim().split('\n')[2]) as { + tags: Record; + exception: { values: { value: string; mechanism: { type: string }; stacktrace: { frames: { filename: string; in_app: boolean }[] } }[] }; + }; + expect(event.tags).toMatchObject({ runtime: 'server', platform: 'desktop' }); + const [value] = event.exception.values; + expect(value.mechanism.type).toBe('desktop-main'); + const frame = value.stacktrace.frames.find((f) => f.filename === '/desktop/main.mjs')!; + expect(frame.in_app).toBe(true); + expect(body).not.toContain('linus'); + expect(body).not.toContain('acme-corp'); + expect(body).not.toContain('inbox.json'); + }); + + 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'); + }); +}); + +describe('a DSN in a config file', () => { + const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3'; + + it('never reaches a page of the desktop app before the user said yes', async () => { + consent.value = 'off'; + flag.dsn = DSN; + expect(await browserErrorSettings()).toMatchObject({ dsn: '', collect: false }); + consent.value = 'instant'; + expect(await browserErrorSettings()).toMatchObject({ dsn: DSN, collect: true }); + }); + + it('stays an admin\'s to set on a server', async () => { + flag.IS_DESKTOP_BUILD = false; + consent.value = 'off'; + flag.dsn = DSN; + expect(await browserErrorSettings()).toMatchObject({ dsn: DSN, collect: true }); + }); +}); + +describe('a server\'s own report', () => { + it('has no desktop tag outside the desktop build', async () => { + flag.IS_DESKTOP_BUILD = false; + flag.dsn = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3'; + await captureDesktopMainError(MAIN); + const event = JSON.parse(String(fetchSpy.mock.calls[0][1].body).trim().split('\n')[2]) as { tags: Record }; + expect(event.tags.platform).toBeUndefined(); + }); +}); diff --git a/lib/error-reporting/__tests__/desktop-reports.test.ts b/lib/error-reporting/__tests__/desktop-reports.test.ts new file mode 100644 index 000000000..bb70874b1 --- /dev/null +++ b/lib/error-reporting/__tests__/desktop-reports.test.ts @@ -0,0 +1,141 @@ +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', () => { + 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('what a report says', () => { + it.each([ + ["ENOENT: no such file or directory, open 'C:\\Users\\linus\\AppData\\Roaming\\Bulwark\\crash.dmp'", ['linus', 'AppData', 'crash']], + ['EPERM: operation not permitted, mkdir C:\\Users\\linus\\mail', ['linus']], + ['failed to read /home/linus/.config/Bulwark/state', ['linus', '.config']], + ['Subject Merger plans for Acme failed from ceo.private@acme-corp.example', ['Merger', 'Acme', 'ceo.private', 'acme-corp']], + ['request failed Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdef sealed', ['eyJ', 'abcdef']], + ['token 0123456789abcdef0123456789abcdef0123 refused', ['0123456789']], + ])('%s', (input, gone) => { + const out = scrubText(input); + for (const word of gone) expect(out).not.toContain(word); + }); +}); + +describe('tagged as the desktop app', () => { + let fetchSpy: ReturnType; + + const eventOf = (init: RequestInit) => JSON.parse(String(init.body).trim().split('\n')[2]) as { tags: Record }; + + beforeEach(() => { + resetErrorReportingForTests(); + sessionStorage.clear(); + fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + setBridge(undefined); + }); + + const bundleError = () => withStack('TypeError: nope\n at a (https://mail.example.org/_next/static/chunks/1.js:2:30)'); + + it('in the app, on the event that goes to a DSN', () => { + setBridge({ version: 1, platform: 'win32' }); + configureErrorReporting(SETTINGS); + captureException(bundleError()); + expect(eventOf(fetchSpy.mock.calls[0][1]).tags).toMatchObject({ runtime: 'browser', platform: 'desktop' }); + }); + + it('not in a browser', () => { + configureErrorReporting(SETTINGS); + captureException(bundleError()); + expect(eventOf(fetchSpy.mock.calls[0][1]).tags.platform).toBeUndefined(); + }); + + it('a page in the app sends nothing with crash reports forced off, even to an instance that asks', () => { + setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: forcedOff }), onChange: () => () => {} } }); + configureErrorReporting({ ...SETTINGS, collect: true }); + captureException(bundleError()); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('a policy that only recommends leaves the instance\'s own settings in charge', () => { + setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: { value: false, locked: false, source: 'recommended' } }), onChange: () => () => {} } }); + configureErrorReporting(SETTINGS); + captureException(bundleError()); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('in the version, in the desktop build only: the collector has no room for a tag', () => { + vi.stubEnv('NEXT_PUBLIC_APP_VERSION', '1.20.0'); + expect(appVersion()).toBe('1.20.0'); + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect(appVersion()).toBe('1.20.0+desktop'); + expect(appVersion()).toMatch(/^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/); + }); +}); diff --git a/lib/error-reporting/client.ts b/lib/error-reporting/client.ts index 2c842fae3..649e29bf4 100644 --- a/lib/error-reporting/client.ts +++ b/lib/error-reporting/client.ts @@ -1,4 +1,5 @@ import { apiFetch, getPathPrefix } from '@/lib/browser-navigation'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; import { scrubRoutePath } from '@/lib/route-groups'; import { debugIdsByPath } from './debug-ids'; import { @@ -176,10 +177,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 +204,12 @@ function deliver(report: ErrorReport): void { platform: 'javascript', fingerprint: print, debugIds: debugIdsByPath(), - tags: { runtime: 'browser', ...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}) }, + tags: { + runtime: 'browser', + ...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}), + // An instance's build knows nothing of the app it's in, so the bridge says. + ...(getDesktopBridge() ? { platform: 'desktop' } : {}), + }, }); send(envelopeUrl(dsn), buildEnvelope(event), { // text/plain keeps it a simple request: no preflight, no custom header. diff --git a/lib/error-reporting/envelope.ts b/lib/error-reporting/envelope.ts index aaad77e1b..e014b08b2 100644 --- a/lib/error-reporting/envelope.ts +++ b/lib/error-reporting/envelope.ts @@ -1,3 +1,4 @@ +import { DESKTOP_FRAME_PREFIX, DESKTOP_VERSION_SUFFIX } from '@/lib/desktop/desktop-telemetry'; import type { Frame } from './stack'; /** @@ -93,7 +94,7 @@ export function buildEvent(report: ErrorReport, ctx: EventContext): Record frame.path))] .filter((path) => ctx.debugIds.has(path)) @@ -144,6 +145,11 @@ export function releaseName(): string { return commit && commit !== 'unknown' ? `${version}+${commit}` : version; } +/** + * In the desktop build with a suffix, which also reaches the collector: its error batches + * have no room for a tag, and its version field allows this. + */ export function appVersion(): string { - return process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; + const version = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; + return process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? `${version}${DESKTOP_VERSION_SUFFIX}` : version; } diff --git a/lib/error-reporting/scrub.ts b/lib/error-reporting/scrub.ts index 9c24601e4..1f4d9fa7c 100644 --- a/lib/error-reporting/scrub.ts +++ b/lib/error-reporting/scrub.ts @@ -14,6 +14,8 @@ * Documented at https://bulwarkmail.org/docs/deployment/monitoring#what-is-removed */ +import { DESKTOP_FRAME_PREFIX } from '@/lib/desktop/desktop-telemetry'; + export const MAX_MESSAGE_LENGTH = 200; const REDACTED = '…'; @@ -130,12 +132,15 @@ export function scrubFunctionName(input: unknown): string | undefined { * The path of a stack frame's file, without origin, base path, query or * fragment: `/_next/static/chunks/x.js` for the browser bundle, * `/.next/server/...` for the server's, `node:...` for Node's own modules, - * `node_modules/...` for dependencies; `[other]` for anything else + * `node_modules/...` for dependencies, `/desktop/...` for the desktop app's main + * process, which the shell rewrites its own files to, so a path on disk never gets + * here; `[other]` for anything else * (extensions, plugin code, evaluated code). */ export function scrubFramePath(input: unknown): string { if (typeof input !== 'string' || input === '') return '[other]'; const path = input.split(/[?#]/, 1)[0].replace(/\\/g, '/'); + if (path.startsWith(DESKTOP_FRAME_PREFIX)) return /^[\w./@+~-]+$/.test(path) && !path.includes('..') ? path : '[other]'; for (const marker of ['/_next/static/', '/.next/server/', '/.next/static/']) { const at = path.indexOf(marker); if (at >= 0) { diff --git a/lib/error-reporting/server.ts b/lib/error-reporting/server.ts index d94e728c3..c735a31a4 100644 --- a/lib/error-reporting/server.ts +++ b/lib/error-reporting/server.ts @@ -9,6 +9,7 @@ import { errorReportingSettings, shouldCollectErrors } from './settings'; import { scrubName, scrubText } from './scrub'; import { parseStack, type Frame } from './stack'; import { debugIdsByPath } from './debug-ids'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; /** * Error reporting on the server: errors Next hands to onRequestError @@ -56,7 +57,7 @@ async function deliver(report: ErrorReport): Promise { platform: 'node', fingerprint: print, debugIds: debugIdsByPath(), - tags: { runtime: 'server' }, + tags: { runtime: 'server', ...(IS_DESKTOP_BUILD ? { platform: 'desktop' } : {}) }, }); s.sending++; try { @@ -95,6 +96,31 @@ export function captureRequestError(error: unknown, routePattern?: string): Prom } } +export interface DesktopMainErrorInput { + name?: unknown; + message?: unknown; + stack?: unknown; + mechanism?: unknown; +} + +/** + * An error in the desktop app's main process, handed over by the shell. Its frames are + * the shell's own files as `/desktop/...`. All of it is scrubbed here again. + */ +export function captureDesktopMainError(error: DesktopMainErrorInput): Promise { + try { + return handleServerReport({ + name: scrubName(error.name), + message: scrubText(error.message), + frames: parseStack(error.stack), + handled: false, + mechanism: error.mechanism === 'unhandledrejection' ? 'desktop-main-rejection' : 'desktop-main', + }); + } catch { + return Promise.resolve(); + } +} + /** * logger.error: a route that caught an error and logged it. The frames are * the logging call's, minus the logger's own `skip` frames. diff --git a/lib/error-reporting/settings.ts b/lib/error-reporting/settings.ts index 0e4097e32..16a561de0 100644 --- a/lib/error-reporting/settings.ts +++ b/lib/error-reporting/settings.ts @@ -2,6 +2,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { parseDsn } from './envelope'; import { metricsEnabled } from '@/lib/metrics'; import { effectiveErrorsConsent } from '@/lib/telemetry/state'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import type { ClientSettings } from './client'; /** @@ -45,6 +46,8 @@ export function errorReportingSettings(): ErrorReportingSettings { */ export async function shouldCollectErrors(): Promise { await configManager.ensureLoaded(); + // The desktop user's own yes, and nothing else: a DSN in a config file isn't consent. + if (IS_DESKTOP_BUILD) return (await effectiveErrorsConsent()).consent !== 'off'; if (errorReportingSettings().dsn || metricsEnabled()) return true; return (await effectiveErrorsConsent()).consent !== 'off'; } @@ -52,5 +55,7 @@ export async function shouldCollectErrors(): Promise { /** What /api/config hands the browser. */ export async function browserErrorSettings(): Promise { const settings = errorReportingSettings(); - return { ...settings, collect: await shouldCollectErrors() }; + const collect = await shouldCollectErrors(); + // A browser sends to the DSN by itself, so it mustn't get one before the user said yes. + return { ...settings, dsn: IS_DESKTOP_BUILD && !collect ? '' : settings.dsn, collect }; } diff --git a/lib/error-reporting/stack.ts b/lib/error-reporting/stack.ts index 6a6b83f00..7a8393ac2 100644 --- a/lib/error-reporting/stack.ts +++ b/lib/error-reporting/stack.ts @@ -33,7 +33,9 @@ export function parseStack(stack: unknown): Frame[] { for (const line of stack.split('\n')) { const match = V8.exec(line) ?? GECKO.exec(line); if (!match) continue; - const [, fn, file, lineNo, column] = match; + const [, fn, rawFile, lineNo, column] = match; + // "at async file:1:2": an anonymous async frame. + const file = rawFile.replace(/^async /, ''); // "at fn (native)" and eval chains carry no file worth keeping. frames.push({ path: scrubFramePath(file), diff --git a/lib/telemetry/payload.ts b/lib/telemetry/payload.ts index 6c56c0e4f..7d0b9fe75 100644 --- a/lib/telemetry/payload.ts +++ b/lib/telemetry/payload.ts @@ -5,6 +5,8 @@ import { effectiveErrorsConsent, getInstanceId } from './state'; import { getLoginCounts } from './login-tracker'; import { isErrorCode } from '@/lib/error-reporting/envelope'; import { readErrors, utcDay, type Aggregate } from '@/lib/error-reporting/aggregate'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { desktopServerPrivacy } from '@/lib/desktop/server-privacy'; import { MAX_TELEMETRY_ERRORS, MAX_TELEMETRY_FRAMES, @@ -23,6 +25,10 @@ export function markProcessStart(): void { } function readPackage(): { version: string; build: string | null } { + // The desktop app's version, not the webmail's it carries, and mode and channel in the + // one field the collector keeps (lib/desktop/desktop-telemetry.ts). + const desktop = desktopServerPrivacy(); + if (desktop) return { version: desktop.appVersion, build: desktop.build }; try { const pkg = JSON.parse( readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'), @@ -34,6 +40,7 @@ function readPackage(): { version: string; build: string | null } { } function detectPlatform(): Platform { + if (IS_DESKTOP_BUILD) return 'desktop'; if (process.env.KUBERNETES_SERVICE_HOST) return 'k8s'; // /.dockerenv is the standard Docker container marker. try { @@ -154,6 +161,8 @@ export function errorsForDay(agg: Aggregate, day: string): TelemetryError[] { */ export async function buildPayload(): Promise { const base = await buildPayloadV1(); + // Crash report consent has its own way out, /v1/errors. A heartbeat is only telemetry. + if (IS_DESKTOP_BUILD) return base; // Daily and instant mode both send the last complete day with the heartbeat. if ((await effectiveErrorsConsent()).consent === 'off') return base; return { ...base, schema: '2', errors: errorsForPayload(await readErrors()) }; diff --git a/lib/telemetry/sender.ts b/lib/telemetry/sender.ts index 5eeab4366..d077c5b45 100644 --- a/lib/telemetry/sender.ts +++ b/lib/telemetry/sender.ts @@ -7,6 +7,8 @@ import { import { buildPayload } from './payload'; import { fetchTelemetryTarget, resolveEndpointAllowed } from './endpoint-guard'; import { DEFAULT_ENDPOINT } from './types'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { DESKTOP_CATCH_UP_MS, DESKTOP_RETRY_MS } from '@/lib/desktop/desktop-telemetry'; const DAY_MS = 24 * 60 * 60 * 1000; const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours @@ -164,12 +166,16 @@ async function scheduleNext(delayMs: number, generation: number): Promise } async function tick(slot: string, generation: number): Promise { + let failed = false; try { - await sendOnce({ reason: 'scheduled', slot }); + const result = await sendOnce({ reason: 'scheduled', slot }); + failed = !result.ok && result.error !== 'already sent'; } catch (err) { + failed = true; logger.warn('telemetry: heartbeat failed', { error: err instanceof Error ? err.message : String(err) }); } - await scheduleNext(jitteredDelay(DAY_MS), generation); + // A laptop that woke up offline shouldn't lose the day. + await scheduleNext(IS_DESKTOP_BUILD && failed ? DESKTOP_RETRY_MS : jitteredDelay(DAY_MS), generation); } // Run by the scheduler leader (lib/scheduler-leader.ts). Idempotent. @@ -187,7 +193,9 @@ export async function startScheduler(): Promise { } // If we have a next-scheduled time in the future use it; otherwise schedule // FIRST_DELAY_MS out. This means after a restart we don't fire immediately. - let delay = FIRST_DELAY_MS; + // A desktop app isn't a server: quit within the hour on every start, and it would + // never send. So what's due goes out soon after a start. + let delay = IS_DESKTOP_BUILD ? DESKTOP_CATCH_UP_MS : FIRST_DELAY_MS; if (state.nextScheduledAt) { const remaining = new Date(state.nextScheduledAt).getTime() - Date.now(); if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS); diff --git a/lib/telemetry/state.ts b/lib/telemetry/state.ts index ca9f8655b..452c90306 100644 --- a/lib/telemetry/state.ts +++ b/lib/telemetry/state.ts @@ -6,6 +6,8 @@ import { isStoreUnavailable, StoredDataUnreadableError } from '@/lib/stored-data import type { TelemetryStateFile, ConsentState, ErrorsConsent } from './types'; import { DEFAULT_ENDPOINT } from './types'; import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { desktopServerPrivacy } from '@/lib/desktop/server-privacy'; export function getDir(): string { return process.env.TELEMETRY_DATA_DIR || @@ -195,6 +197,9 @@ export async function effectiveErrorsConsent(): Promise<{ }> { const telemetry = await effectiveConsent(); if (telemetry.source === 'no-third-party') return { consent: 'off', source: 'no-third-party' }; + // The desktop user's crash report consent, separate from telemetry: either can be on + // without the other. Instant, because without a heartbeat nothing else carries them. + if (IS_DESKTOP_BUILD) return { consent: desktopServerPrivacy()?.crashReports ? 'instant' : 'off', source: 'env' }; if (telemetry.consent !== 'on') return { consent: 'off', source: 'telemetry' }; const env = errorsEnvOverride(); if (env) return { consent: env, source: 'env' }; @@ -213,6 +218,8 @@ export async function effectiveConsent(): Promise<{ const envState = envOverride(); const state = await loadState(); if (await isNoThirdPartyTrafficOn()) return { consent: 'off', source: 'no-third-party', state }; + // The user answers in the shell, and nothing on disk or in the environment beats that. + if (IS_DESKTOP_BUILD) return { consent: desktopServerPrivacy()?.telemetry ? 'on' : 'off', source: 'env', state }; if (envState) return { consent: envState, source: 'env', state }; return { consent: state.consent, source: 'file', state }; } diff --git a/lib/telemetry/types.ts b/lib/telemetry/types.ts index 21deb9eb4..bdbc9c9fb 100644 --- a/lib/telemetry/types.ts +++ b/lib/telemetry/types.ts @@ -4,7 +4,7 @@ export type ConsentState = 'pending' | 'on' | 'off'; -export type Platform = 'docker' | 'bare' | 'k8s' | 'unknown'; +export type Platform = 'docker' | 'bare' | 'k8s' | 'desktop' | 'unknown'; export type OsFamily = 'linux' | 'darwin' | 'windows' | 'unknown'; export type CountBucket = '0' | '1' | '2-5' | '6-10' | '11-50' | '51-200' | '201+'; diff --git a/locales/ar/common.json b/locales/ar/common.json index 6c467aa5c..13b0896ec 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "أوقفت مؤسستك الإضافات على هذا الجهاز.", "plugins_allowlist": "لا تسمح مؤسستك إلا بإضافات معينة على هذا الجهاز.", "off": "إيقاف" + }, + "desktopTelemetry": { + "section_title": "معلومات الاستخدام وتقارير الأعطال", + "section_description": "كلاهما متوقف حتى تشغّله، ولكل منهما مفتاحه الخاص.", + "telemetry_label": "إرسال معلومات الاستخدام", + "telemetry_description": "مرة واحدة في اليوم: إصدار التطبيق ونظام التشغيل وطريقة اتصال التطبيق. ومع خادم بريد خاص بك، أيضًا الميزات المفعّلة وعدد تقريبي للحسابات. لا يُرسل أبدًا البريد أو العناوين أو أسماء الخوادم.", + "crash_label": "إرسال تقارير الأعطال", + "crash_description": "تُرسل أخطاء التطبيق مع أسماء ملفات من شيفرة التطبيق نفسه ورسائل قصيرة منقّحة. وإذا تعطّل التطبيق نفسه، يبقى ملف العطل على هذا الحاسوب ولا يغادره إلا إذا أرسلته.", + "crash_memory": "ملف العطل نسخة من جزء من ذاكرة التطبيق لحظة العطل، لذا قد يحتوي على أجزاء من رسائل مفتوحة. يزيل Bulwark ما يجده من أسماء المستخدمين والعناوين ورموز تسجيل الدخول، لكنه لا يَعِد بالعثور على كل شيء.", + "instance_note": "على خادم Bulwark، يقرر مسؤول ذلك الخادم بشكل منفصل ما تبلّغه صفحاته به.", + "forced_off": "أوقفت مؤسستك هذا الخيار.", + "url_label": "وجهة ملفات الأعطال", + "url_description": "عنوان https الذي يستقبل ملف العطل. لا يأتي Bulwark بعنوان.", + "url_invalid": "أدخل عنوان https كاملًا.", + "url_save": "حفظ", + "files_label": "ملفات الأعطال على هذا الحاسوب", + "files_description": "يحتفظ Bulwark بالأحدث ويحذف ما مضى عليه أكثر من شهر.", + "files_empty": "لا توجد ملفات أعطال.", + "file_size": "{size} ك.ب", + "send": "إرسال تقرير العطل…", + "delete_all": "حذف الكل", + "sent": "أُرسل تقرير العطل.", + "send_no_consent": "شغّل تقارير الأعطال أولًا.", + "send_no_url": "لم يُحدَّد عنوان، لذا لا توجد وجهة للإرسال. أدخل عنوانًا في الأعلى.", + "send_failed": "تعذّر إرسال تقرير العطل." } } diff --git a/locales/ca/common.json b/locales/ca/common.json index f793ac547..26e252d74 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "La teva organització ha desactivat els connectors en aquest dispositiu.", "plugins_allowlist": "La teva organització només permet determinats connectors en aquest dispositiu.", "off": "Desactivat" + }, + "desktopTelemetry": { + "section_title": "Dades d'ús i informes d'errors greus", + "section_description": "Tots dos estan desactivats fins que els activis, i cadascun té el seu interruptor.", + "telemetry_label": "Envia dades d'ús", + "telemetry_description": "Un cop al dia: la versió de l'aplicació, el teu sistema operatiu i com es connecta l'aplicació. Amb un servidor de correu propi, també quines funcions estan actives i un nombre aproximat de comptes. Mai correus, adreces ni noms de servidor.", + "crash_label": "Envia informes d'errors greus", + "crash_description": "Els errors de l'aplicació s'envien amb noms de fitxer del codi de l'aplicació i missatges curts i nets. Si la mateixa aplicació es tanca per un error, un fitxer d'error queda en aquest ordinador i només en surt si l'envies.", + "crash_memory": "Un fitxer d'error és una còpia d'una part de la memòria de l'aplicació en el moment de l'error, de manera que pot contenir parts de correus oberts. Bulwark elimina els noms d'usuari, les adreces i els testimonis d'inici de sessió que troba, però no pot prometre trobar-ho tot.", + "instance_note": "En un servidor Bulwark, el seu administrador decideix per separat què li comuniquen les pàgines.", + "forced_off": "La teva organització ho ha desactivat.", + "url_label": "Destinació dels fitxers d'error", + "url_description": "L'adreça https que rep un fitxer d'error. Bulwark no n'inclou cap.", + "url_invalid": "Escriu una adreça https completa.", + "url_save": "Desa", + "files_label": "Fitxers d'error en aquest ordinador", + "files_description": "Bulwark conserva els més recents i elimina els de més d'un mes.", + "files_empty": "No hi ha fitxers d'error.", + "file_size": "{size} KB", + "send": "Envia l'informe d'error…", + "delete_all": "Elimina-ho tot", + "sent": "Informe d'error enviat.", + "send_no_consent": "Activa primer els informes d'errors greus.", + "send_no_url": "No hi ha cap adreça, així que no hi ha on enviar-ho. Escriu-ne una a dalt.", + "send_failed": "No s'ha pogut enviar l'informe d'error." } } diff --git a/locales/cs/common.json b/locales/cs/common.json index a873b79b5..5311ef7ee 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Vaše organizace na tomto zařízení vypnula pluginy.", "plugins_allowlist": "Vaše organizace na tomto zařízení povoluje jen některé pluginy.", "off": "Vypnuto" + }, + "desktopTelemetry": { + "section_title": "Údaje o používání a hlášení o pádech", + "section_description": "Obojí je vypnuté, dokud to nezapnete, a každé má vlastní přepínač.", + "telemetry_label": "Odesílat údaje o používání", + "telemetry_description": "Jednou denně: verze aplikace, váš operační systém a způsob připojení aplikace. U vlastního poštovního serveru také zapnuté funkce a přibližný počet účtů. Nikdy pošta, adresy ani názvy serverů.", + "crash_label": "Odesílat hlášení o pádech", + "crash_description": "Chyby v aplikaci se odesílají s názvy souborů z kódu aplikace a krátkými, očištěnými zprávami. Když spadne sama aplikace, soubor o pádu zůstane v tomto počítači a odejde jen tehdy, když ho odešlete.", + "crash_memory": "Soubor o pádu je kopie části paměti aplikace v okamžiku pádu, takže může obsahovat části otevřené pošty. Bulwark odstraní uživatelská jména, adresy a přihlašovací tokeny, které najde, ale nemůže slíbit, že najde vše.", + "instance_note": "Na serveru Bulwark rozhoduje jeho správce zvlášť o tom, co mu stránky hlásí.", + "forced_off": "Vaše organizace to vypnula.", + "url_label": "Kam jdou soubory o pádu", + "url_description": "Adresa https, která přijímá soubor o pádu. Bulwark žádnou nemá.", + "url_invalid": "Zadejte úplnou adresu https.", + "url_save": "Uložit", + "files_label": "Soubory o pádu v tomto počítači", + "files_description": "Bulwark ponechá ty nejnovější a smaže ty starší než měsíc.", + "files_empty": "Žádné soubory o pádu.", + "file_size": "{size} KB", + "send": "Odeslat hlášení o pádu…", + "delete_all": "Smazat vše", + "sent": "Hlášení o pádu odesláno.", + "send_no_consent": "Nejprve zapněte hlášení o pádech.", + "send_no_url": "Není nastavena žádná adresa, takže to nejde kam odeslat. Zadejte ji výše.", + "send_failed": "Hlášení o pádu se nepodařilo odeslat." } } diff --git a/locales/da/common.json b/locales/da/common.json index 58c79b036..801f3f147 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Din organisation har slået plugins fra på denne enhed.", "plugins_allowlist": "Din organisation tillader kun bestemte plugins på denne enhed.", "off": "Fra" + }, + "desktopTelemetry": { + "section_title": "Brugsdata og nedbrudsrapporter", + "section_description": "Begge er slået fra, indtil du slår dem til, og hver har sin egen kontakt.", + "telemetry_label": "Send brugsdata", + "telemetry_description": "Én gang om dagen: appens version, dit styresystem og hvordan appen forbinder. Med din egen mailserver også hvilke funktioner der er slået til og et omtrentligt antal konti. Aldrig mails, adresser eller servernavne.", + "crash_label": "Send nedbrudsrapporter", + "crash_description": "Fejl i appen sendes med filnavne fra appens egen kode og korte, rensede beskeder. Hvis selve appen går ned, bliver en nedbrudsfil på denne computer og forlader den kun, hvis du sender den.", + "crash_memory": "En nedbrudsfil er en kopi af en del af appens hukommelse, da den gik ned, så den kan indeholde dele af åbne mails. Bulwark fjerner de brugernavne, adresser og login-tokens, det finder, men kan ikke love at finde alt.", + "instance_note": "På en Bulwark-server bestemmer dens administrator særskilt, hvad siderne melder til den.", + "forced_off": "Din organisation har slået dette fra.", + "url_label": "Hvor nedbrudsfiler sendes hen", + "url_description": "Den https-adresse, der modtager en nedbrudsfil. Bulwark har ingen med.", + "url_invalid": "Skriv en fuld https-adresse.", + "url_save": "Gem", + "files_label": "Nedbrudsfiler på denne computer", + "files_description": "Bulwark beholder de nyeste og sletter dem, der er ældre end en måned.", + "files_empty": "Ingen nedbrudsfiler.", + "file_size": "{size} KB", + "send": "Send nedbrudsrapport…", + "delete_all": "Slet alle", + "sent": "Nedbrudsrapport sendt.", + "send_no_consent": "Slå først nedbrudsrapporter til.", + "send_no_url": "Der er ikke angivet en adresse, så den kan ikke sendes nogen steder. Skriv en ovenfor.", + "send_failed": "Nedbrudsrapporten kunne ikke sendes." } } diff --git a/locales/de/common.json b/locales/de/common.json index 0388c8994..3138a9d66 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Ihre Organisation hat Plugins auf diesem Gerät ausgeschaltet.", "plugins_allowlist": "Ihre Organisation erlaubt auf diesem Gerät nur bestimmte Plugins.", "off": "Aus" + }, + "desktopTelemetry": { + "section_title": "Nutzungsdaten und Absturzberichte", + "section_description": "Beides bleibt aus, bis Sie es einschalten, und jedes hat einen eigenen Schalter.", + "telemetry_label": "Nutzungsdaten senden", + "telemetry_description": "Einmal am Tag: die App-Version, Ihr Betriebssystem und die Art der Verbindung. Bei einem eigenen Mailserver auch, welche Funktionen aktiv sind, und eine ungefähre Zahl der Konten. Niemals E-Mails, Adressen oder Servernamen.", + "crash_label": "Absturzberichte senden", + "crash_description": "Fehler in der App werden mit Dateinamen aus dem eigenen Code der App und kurzen, bereinigten Meldungen gesendet. Stürzt die App selbst ab, bleibt eine Absturzdatei auf diesem Computer und verlässt ihn nur, wenn Sie sie senden.", + "crash_memory": "Eine Absturzdatei ist eine Kopie eines Teils des Arbeitsspeichers der App beim Absturz und kann daher Teile geöffneter E-Mails enthalten. Bulwark entfernt Benutzernamen, Adressen und Anmeldetokens, die es findet, kann aber nicht versprechen, alles zu finden.", + "instance_note": "Bei einem Bulwark-Server entscheidet dessen Administrator getrennt davon, was seine Seiten an ihn melden.", + "forced_off": "Ihre Organisation hat das ausgeschaltet.", + "url_label": "Ziel der Absturzdateien", + "url_description": "Die https-Adresse, die eine Absturzdatei empfängt. Bulwark bringt keine mit.", + "url_invalid": "Geben Sie eine vollständige https-Adresse ein.", + "url_save": "Speichern", + "files_label": "Absturzdateien auf diesem Computer", + "files_description": "Bulwark behält die neuesten und löscht alles, was älter als einen Monat ist.", + "files_empty": "Keine Absturzdateien.", + "file_size": "{size} KB", + "send": "Absturzbericht senden…", + "delete_all": "Alle löschen", + "sent": "Absturzbericht gesendet.", + "send_no_consent": "Schalten Sie zuerst die Absturzberichte ein.", + "send_no_url": "Es ist keine Adresse eingetragen, daher gibt es kein Ziel. Geben Sie oben eine ein.", + "send_failed": "Der Absturzbericht konnte nicht gesendet werden." } } diff --git a/locales/en/common.json b/locales/en/common.json index c7cf59956..fd3b6a995 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Your organisation turned off plugins on this device.", "plugins_allowlist": "Your organisation only allows certain plugins on this device.", "off": "Off" + }, + "desktopTelemetry": { + "section_title": "Usage info and crash reports", + "section_description": "Both are off until you turn them on, and each has its own switch.", + "telemetry_label": "Send usage info", + "telemetry_description": "Once a day: the app version, your operating system and how the app connects. With your own mail server, also which features are on and a rough number of accounts. Never mail, addresses or server names.", + "crash_label": "Send crash reports", + "crash_description": "Errors in the app go out with file names from the app's own code and short, cleaned-up messages. If the app itself crashes, a crash file stays on this computer and only leaves when you send it.", + "crash_memory": "A crash file is a copy of part of the app's memory when it crashed, so it can contain parts of open mail. Bulwark removes user names, addresses and sign-in tokens it can find, but it can't promise to find everything.", + "instance_note": "On a Bulwark server, that server's admin decides separately what its pages report to it.", + "forced_off": "Your organisation turned this off.", + "url_label": "Where crash files go", + "url_description": "The https address that receives a crash file. Bulwark doesn't come with one.", + "url_invalid": "Enter a full https address.", + "url_save": "Save", + "files_label": "Crash files on this computer", + "files_description": "Bulwark keeps the newest few and deletes ones older than a month.", + "files_empty": "No crash files.", + "file_size": "{size} KB", + "send": "Send crash report…", + "delete_all": "Delete all", + "sent": "Crash report sent.", + "send_no_consent": "Turn on crash reports first.", + "send_no_url": "No address is set, so there's nowhere to send this. Enter one above.", + "send_failed": "The crash report couldn't be sent." } } diff --git a/locales/es/common.json b/locales/es/common.json index 35c0af539..269191871 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Tu organización ha desactivado los complementos en este dispositivo.", "plugins_allowlist": "Tu organización solo permite determinados complementos en este dispositivo.", "off": "Desactivado" + }, + "desktopTelemetry": { + "section_title": "Datos de uso e informes de fallos", + "section_description": "Ambos están desactivados hasta que los actives, y cada uno tiene su propio interruptor.", + "telemetry_label": "Enviar datos de uso", + "telemetry_description": "Una vez al día: la versión de la aplicación, tu sistema operativo y cómo se conecta la aplicación. Con tu propio servidor de correo, también qué funciones están activas y un número aproximado de cuentas. Nunca correos, direcciones ni nombres de servidor.", + "crash_label": "Enviar informes de fallos", + "crash_description": "Los errores de la aplicación se envían con nombres de archivo del propio código de la aplicación y mensajes breves y limpios. Si la propia aplicación falla, un archivo de fallo queda en este ordenador y solo sale de él si lo envías.", + "crash_memory": "Un archivo de fallo es una copia de parte de la memoria de la aplicación en el momento del fallo, así que puede contener partes de correos abiertos. Bulwark elimina los nombres de usuario, las direcciones y los tokens de inicio de sesión que encuentra, pero no puede prometer encontrarlo todo.", + "instance_note": "En un servidor Bulwark, su administrador decide por separado qué le comunican sus páginas.", + "forced_off": "Tu organización ha desactivado esto.", + "url_label": "Destino de los archivos de fallo", + "url_description": "La dirección https que recibe un archivo de fallo. Bulwark no incluye ninguna.", + "url_invalid": "Escribe una dirección https completa.", + "url_save": "Guardar", + "files_label": "Archivos de fallo en este ordenador", + "files_description": "Bulwark conserva los más recientes y elimina los de más de un mes.", + "files_empty": "No hay archivos de fallo.", + "file_size": "{size} KB", + "send": "Enviar informe de fallo…", + "delete_all": "Eliminar todo", + "sent": "Informe de fallo enviado.", + "send_no_consent": "Activa primero los informes de fallos.", + "send_no_url": "No hay ninguna dirección, así que no hay adónde enviarlo. Escribe una arriba.", + "send_failed": "No se ha podido enviar el informe de fallo." } } diff --git a/locales/et/common.json b/locales/et/common.json index d0fc05409..9722d8062 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Sinu organisatsioon on selles seadmes pistikprogrammid välja lülitanud.", "plugins_allowlist": "Sinu organisatsioon lubab selles seadmes ainult teatud pistikprogramme.", "off": "Väljas" + }, + "desktopTelemetry": { + "section_title": "Kasutusandmed ja kokkujooksmise aruanded", + "section_description": "Mõlemad on välja lülitatud, kuni need sisse lülitate, ja kummalgi on oma lüliti.", + "telemetry_label": "Saada kasutusandmed", + "telemetry_description": "Kord päevas: rakenduse versioon, teie operatsioonisüsteem ja rakenduse ühendusviis. Oma meiliserveri puhul ka see, millised funktsioonid on sees, ja kontode ligikaudne arv. Mitte kunagi kirju, aadresse ega serverinimesid.", + "crash_label": "Saada kokkujooksmise aruanded", + "crash_description": "Rakenduse vead saadetakse koos rakenduse enda koodi failinimede ja lühikeste puhastatud teadetega. Kui rakendus ise kokku jookseb, jääb kokkujooksmise fail sellesse arvutisse ja lahkub sealt ainult siis, kui selle saadate.", + "crash_memory": "Kokkujooksmise fail on koopia osast rakenduse mälust kokkujooksmise hetkel, seega võib see sisaldada avatud kirjade osi. Bulwark eemaldab leitud kasutajanimed, aadressid ja sisselogimislubad, kuid ei saa lubada, et leiab kõik.", + "instance_note": "Bulwarki serveris otsustab selle administraator eraldi, mida lehed talle teatavad.", + "forced_off": "Teie organisatsioon on selle välja lülitanud.", + "url_label": "Kuhu kokkujooksmise failid lähevad", + "url_description": "Https-aadress, mis võtab kokkujooksmise faili vastu. Bulwarkil pole oma aadressi.", + "url_invalid": "Sisestage täielik https-aadress.", + "url_save": "Salvesta", + "files_label": "Kokkujooksmise failid selles arvutis", + "files_description": "Bulwark hoiab alles uusimad ja kustutab kuust vanemad.", + "files_empty": "Kokkujooksmise faile pole.", + "file_size": "{size} KB", + "send": "Saada kokkujooksmise aruanne…", + "delete_all": "Kustuta kõik", + "sent": "Kokkujooksmise aruanne saadetud.", + "send_no_consent": "Lülitage esmalt kokkujooksmise aruanded sisse.", + "send_no_url": "Aadressi pole määratud, seega pole seda kuhugi saata. Sisestage see ülal.", + "send_failed": "Kokkujooksmise aruannet ei õnnestunud saata." } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 5aef56be6..6b3340d5a 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "سازمان شما افزونه‌ها را در این دستگاه خاموش کرده است.", "plugins_allowlist": "سازمان شما در این دستگاه فقط برخی افزونه‌ها را مجاز می‌داند.", "off": "خاموش" + }, + "desktopTelemetry": { + "section_title": "اطلاعات استفاده و گزارش‌های خرابی", + "section_description": "هر دو خاموش هستند تا زمانی که روشنشان کنید و هرکدام کلید خودش را دارد.", + "telemetry_label": "ارسال اطلاعات استفاده", + "telemetry_description": "روزی یک بار: نسخهٔ برنامه، سیستم‌عامل شما و نحوهٔ اتصال برنامه. با سرور ایمیل شخصی، همچنین قابلیت‌های روشن و تعداد تقریبی حساب‌ها. هرگز ایمیل، نشانی یا نام سرور ارسال نمی‌شود.", + "crash_label": "ارسال گزارش‌های خرابی", + "crash_description": "خطاهای برنامه همراه با نام فایل‌های کد خود برنامه و پیام‌های کوتاه پاک‌سازی‌شده ارسال می‌شوند. اگر خود برنامه از کار بیفتد، یک فایل خرابی روی این رایانه می‌ماند و فقط وقتی شما ارسالش کنید خارج می‌شود.", + "crash_memory": "فایل خرابی کپی بخشی از حافظهٔ برنامه در لحظهٔ خرابی است، پس ممکن است بخش‌هایی از ایمیل‌های باز را داشته باشد. Bulwark نام‌های کاربری، نشانی‌ها و توکن‌های ورودی را که پیدا کند حذف می‌کند، اما نمی‌تواند قول دهد همه را پیدا کند.", + "instance_note": "در سرور Bulwark، مدیر آن سرور جداگانه تصمیم می‌گیرد صفحه‌ها چه چیزی به آن گزارش دهند.", + "forced_off": "سازمان شما این را خاموش کرده است.", + "url_label": "مقصد فایل‌های خرابی", + "url_description": "نشانی https که فایل خرابی را دریافت می‌کند. Bulwark نشانی پیش‌فرضی ندارد.", + "url_invalid": "یک نشانی https کامل وارد کنید.", + "url_save": "ذخیره", + "files_label": "فایل‌های خرابی روی این رایانه", + "files_description": "Bulwark جدیدترین‌ها را نگه می‌دارد و قدیمی‌تر از یک ماه را حذف می‌کند.", + "files_empty": "فایل خرابی وجود ندارد.", + "file_size": "{size} کیلوبایت", + "send": "ارسال گزارش خرابی…", + "delete_all": "حذف همه", + "sent": "گزارش خرابی ارسال شد.", + "send_no_consent": "ابتدا گزارش‌های خرابی را روشن کنید.", + "send_no_url": "نشانی‌ای تنظیم نشده، پس جایی برای ارسال نیست. در بالا یکی وارد کنید.", + "send_failed": "گزارش خرابی ارسال نشد." } } diff --git a/locales/fr/common.json b/locales/fr/common.json index 277d30d79..2c58b6651 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Votre organisation a désactivé les plugins sur cet appareil.", "plugins_allowlist": "Votre organisation n'autorise que certains plugins sur cet appareil.", "off": "Désactivé" + }, + "desktopTelemetry": { + "section_title": "Données d'utilisation et rapports de plantage", + "section_description": "Les deux restent désactivés tant que vous ne les activez pas, et chacun a son propre interrupteur.", + "telemetry_label": "Envoyer les données d'utilisation", + "telemetry_description": "Une fois par jour : la version de l'application, votre système d'exploitation et le mode de connexion. Avec votre propre serveur de messagerie, aussi les fonctions activées et un nombre approximatif de comptes. Jamais de messages, d'adresses ni de noms de serveur.", + "crash_label": "Envoyer les rapports de plantage", + "crash_description": "Les erreurs de l'application partent avec les noms de fichiers du code de l'application et de courts messages nettoyés. Si l'application elle-même plante, un fichier de plantage reste sur cet ordinateur et n'en sort que si vous l'envoyez.", + "crash_memory": "Un fichier de plantage est une copie d'une partie de la mémoire de l'application au moment du plantage. Il peut donc contenir des parties de messages ouverts. Bulwark retire les noms d'utilisateur, les adresses et les jetons de connexion qu'il trouve, mais ne peut pas promettre de tout trouver.", + "instance_note": "Sur un serveur Bulwark, son administrateur décide séparément de ce que ses pages lui signalent.", + "forced_off": "Votre organisation a désactivé cette option.", + "url_label": "Destination des fichiers de plantage", + "url_description": "L'adresse https qui reçoit un fichier de plantage. Bulwark n'en fournit aucune.", + "url_invalid": "Saisissez une adresse https complète.", + "url_save": "Enregistrer", + "files_label": "Fichiers de plantage sur cet ordinateur", + "files_description": "Bulwark garde les plus récents et supprime ceux de plus d'un mois.", + "files_empty": "Aucun fichier de plantage.", + "file_size": "{size} Ko", + "send": "Envoyer le rapport de plantage…", + "delete_all": "Tout supprimer", + "sent": "Rapport de plantage envoyé.", + "send_no_consent": "Activez d'abord les rapports de plantage.", + "send_no_url": "Aucune adresse n'est définie, il n'y a donc nulle part où envoyer ce rapport. Saisissez-en une ci-dessus.", + "send_failed": "Le rapport de plantage n'a pas pu être envoyé." } } diff --git a/locales/he/common.json b/locales/he/common.json index 58dfb56e7..41005c2c2 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6632,5 +6632,30 @@ "plugins_off": "הארגון שלך כיבה תוספים במכשיר הזה.", "plugins_allowlist": "הארגון שלך מאפשר במכשיר הזה רק תוספים מסוימים.", "off": "כבוי" + }, + "desktopTelemetry": { + "section_title": "מידע על שימוש ודוחות קריסה", + "section_description": "שניהם כבויים עד שתפעילו אותם, ולכל אחד מתג משלו.", + "telemetry_label": "שליחת מידע על שימוש", + "telemetry_description": "פעם ביום: גרסת האפליקציה, מערכת ההפעלה ואופן החיבור של האפליקציה. עם שרת דואר משלכם, גם אילו תכונות פעילות ומספר משוער של חשבונות. אף פעם לא דואר, כתובות או שמות שרתים.", + "crash_label": "שליחת דוחות קריסה", + "crash_description": "שגיאות באפליקציה נשלחות עם שמות קבצים מהקוד של האפליקציה עצמה והודעות קצרות ומנוקות. אם האפליקציה עצמה קורסת, קובץ קריסה נשאר במחשב הזה ויוצא ממנו רק אם תשלחו אותו.", + "crash_memory": "קובץ קריסה הוא העתק של חלק מזיכרון האפליקציה ברגע הקריסה, ולכן הוא עשוי להכיל חלקים מהודעות דואר פתוחות. Bulwark מסיר שמות משתמש, כתובות ואסימוני התחברות שהוא מוצא, אבל לא יכול להבטיח שימצא הכול.", + "instance_note": "בשרת Bulwark, מנהל השרת מחליט בנפרד מה הדפים מדווחים לו.", + "forced_off": "הארגון שלכם כיבה את זה.", + "url_label": "לאן קובצי הקריסה הולכים", + "url_description": "כתובת ה-https שמקבלת קובץ קריסה. Bulwark לא מגיע עם כתובת.", + "url_invalid": "הזינו כתובת https מלאה.", + "url_save": "שמירה", + "files_label": "קובצי קריסה במחשב הזה", + "files_description": "Bulwark שומר את החדשים ביותר ומוחק קבצים בני יותר מחודש.", + "files_empty": "אין קובצי קריסה.", + "file_size": "{size} ק״ב", + "send": "שליחת דוח קריסה…", + "delete_all": "מחיקת הכול", + "sent": "דוח הקריסה נשלח.", + "send_no_consent": "הפעילו קודם את דוחות הקריסה.", + "send_no_url": "לא הוגדרה כתובת, ולכן אין לאן לשלוח. הזינו כתובת למעלה.", + "send_failed": "לא ניתן היה לשלוח את דוח הקריסה." } } diff --git a/locales/hu/common.json b/locales/hu/common.json index 410ba5ff5..725c9b438 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "A szervezete ezen az eszközön kikapcsolta a bővítményeket.", "plugins_allowlist": "A szervezete ezen az eszközön csak bizonyos bővítményeket engedélyez.", "off": "Ki" + }, + "desktopTelemetry": { + "section_title": "Használati adatok és összeomlási jelentések", + "section_description": "Mindkettő ki van kapcsolva, amíg be nem kapcsolja, és mindegyiknek saját kapcsolója van.", + "telemetry_label": "Használati adatok küldése", + "telemetry_description": "Naponta egyszer: az alkalmazás verziója, az operációs rendszer és az alkalmazás kapcsolódási módja. Saját levelezőszervernél azt is, mely funkciók vannak bekapcsolva, és a fiókok hozzávetőleges számát. Soha nem küld leveleket, címeket vagy szerverneveket.", + "crash_label": "Összeomlási jelentések küldése", + "crash_description": "Az alkalmazás hibái az alkalmazás saját kódjának fájlneveivel és rövid, megtisztított üzenetekkel mennek ki. Ha maga az alkalmazás omlik össze, egy összeomlási fájl marad ezen a számítógépen, és csak akkor kerül ki, ha elküldi.", + "crash_memory": "Az összeomlási fájl az alkalmazás memóriájának egy részéről készült másolat az összeomlás pillanatából, így megnyitott levelek részeit is tartalmazhatja. A Bulwark eltávolítja a talált felhasználóneveket, címeket és bejelentkezési tokeneket, de nem ígérheti, hogy mindent megtalál.", + "instance_note": "Bulwark-szerveren annak rendszergazdája külön dönt arról, mit jelentenek neki az oldalak.", + "forced_off": "A szervezete ezt kikapcsolta.", + "url_label": "Hová kerülnek az összeomlási fájlok", + "url_description": "A https-cím, amely fogadja az összeomlási fájlt. A Bulwark nem ad ilyet.", + "url_invalid": "Adjon meg egy teljes https-címet.", + "url_save": "Mentés", + "files_label": "Összeomlási fájlok ezen a számítógépen", + "files_description": "A Bulwark megtartja a legújabbakat, és törli az egy hónapnál régebbieket.", + "files_empty": "Nincsenek összeomlási fájlok.", + "file_size": "{size} KB", + "send": "Összeomlási jelentés küldése…", + "delete_all": "Összes törlése", + "sent": "Összeomlási jelentés elküldve.", + "send_no_consent": "Először kapcsolja be az összeomlási jelentéseket.", + "send_no_url": "Nincs megadva cím, így nincs hová küldeni. Adjon meg egyet fent.", + "send_failed": "Az összeomlási jelentést nem sikerült elküldeni." } } diff --git a/locales/it/common.json b/locales/it/common.json index 051aca7a3..8df276b33 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "La tua organizzazione ha disattivato i plugin su questo dispositivo.", "plugins_allowlist": "La tua organizzazione consente solo alcuni plugin su questo dispositivo.", "off": "Disattivato" + }, + "desktopTelemetry": { + "section_title": "Dati di utilizzo e segnalazioni di arresto anomalo", + "section_description": "Entrambi restano disattivati finché non li attivi, e ognuno ha il proprio interruttore.", + "telemetry_label": "Invia dati di utilizzo", + "telemetry_description": "Una volta al giorno: la versione dell'app, il tuo sistema operativo e come si connette l'app. Con un tuo server di posta, anche quali funzioni sono attive e un numero approssimativo di account. Mai email, indirizzi o nomi di server.", + "crash_label": "Invia segnalazioni di arresto anomalo", + "crash_description": "Gli errori dell'app vengono inviati con i nomi dei file del codice dell'app e brevi messaggi ripuliti. Se l'app stessa si arresta, un file di arresto resta su questo computer e ne esce solo se lo invii.", + "crash_memory": "Un file di arresto è una copia di parte della memoria dell'app al momento dell'arresto, quindi può contenere parti di email aperte. Bulwark rimuove i nomi utente, gli indirizzi e i token di accesso che trova, ma non può promettere di trovare tutto.", + "instance_note": "Su un server Bulwark, il suo amministratore decide separatamente cosa le sue pagine gli segnalano.", + "forced_off": "La tua organizzazione ha disattivato questa opzione.", + "url_label": "Destinazione dei file di arresto", + "url_description": "L'indirizzo https che riceve un file di arresto. Bulwark non ne include nessuno.", + "url_invalid": "Inserisci un indirizzo https completo.", + "url_save": "Salva", + "files_label": "File di arresto su questo computer", + "files_description": "Bulwark conserva i più recenti ed elimina quelli più vecchi di un mese.", + "files_empty": "Nessun file di arresto.", + "file_size": "{size} KB", + "send": "Invia segnalazione di arresto…", + "delete_all": "Elimina tutti", + "sent": "Segnalazione di arresto inviata.", + "send_no_consent": "Attiva prima le segnalazioni di arresto anomalo.", + "send_no_url": "Non è impostato alcun indirizzo, quindi non c'è dove inviarla. Inseriscine uno qui sopra.", + "send_failed": "Impossibile inviare la segnalazione di arresto." } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 3b7a425db..20534ff70 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "組織はこのデバイスのプラグインをオフにしています。", "plugins_allowlist": "組織はこのデバイスで特定のプラグインのみを許可しています。", "off": "オフ" + }, + "desktopTelemetry": { + "section_title": "利用情報とクラッシュレポート", + "section_description": "どちらもオンにするまでオフのままで、それぞれに専用のスイッチがあります。", + "telemetry_label": "利用情報を送信", + "telemetry_description": "1日に1回、アプリのバージョン、OS、アプリの接続方法を送信します。自分のメールサーバーを使う場合は、有効な機能とおおよそのアカウント数も送信します。メール、アドレス、サーバー名は送信しません。", + "crash_label": "クラッシュレポートを送信", + "crash_description": "アプリのエラーは、アプリ自身のコードのファイル名と、整理された短いメッセージとともに送信されます。アプリ自体がクラッシュした場合、クラッシュファイルはこのコンピューターに残り、あなたが送信したときだけ外に出ます。", + "crash_memory": "クラッシュファイルはクラッシュ時のアプリのメモリの一部のコピーで、開いていたメールの一部が含まれることがあります。Bulwark は見つけたユーザー名、アドレス、サインイントークンを削除しますが、すべてを見つけられるとは限りません。", + "instance_note": "Bulwark サーバーでは、ページがサーバーに何を報告するかはそのサーバーの管理者が別途決めます。", + "forced_off": "組織によってオフにされています。", + "url_label": "クラッシュファイルの送信先", + "url_description": "クラッシュファイルを受け取る https アドレスです。Bulwark には用意されていません。", + "url_invalid": "完全な https アドレスを入力してください。", + "url_save": "保存", + "files_label": "このコンピューター上のクラッシュファイル", + "files_description": "Bulwark は新しいものを保持し、1か月より古いものを削除します。", + "files_empty": "クラッシュファイルはありません。", + "file_size": "{size} KB", + "send": "クラッシュレポートを送信…", + "delete_all": "すべて削除", + "sent": "クラッシュレポートを送信しました。", + "send_no_consent": "先にクラッシュレポートをオンにしてください。", + "send_no_url": "アドレスが設定されていないため、送信先がありません。上で入力してください。", + "send_failed": "クラッシュレポートを送信できませんでした。" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index b453ba6e2..d44489e0d 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "조직에서 이 기기의 플러그인을 껐습니다.", "plugins_allowlist": "조직에서 이 기기에 특정 플러그인만 허용합니다.", "off": "끔" + }, + "desktopTelemetry": { + "section_title": "사용 정보 및 충돌 보고서", + "section_description": "둘 다 켤 때까지 꺼져 있으며, 각각 별도의 스위치가 있습니다.", + "telemetry_label": "사용 정보 보내기", + "telemetry_description": "하루에 한 번: 앱 버전, 운영 체제, 앱의 연결 방식을 보냅니다. 자체 메일 서버를 쓰면 켜진 기능과 대략적인 계정 수도 보냅니다. 메일, 주소, 서버 이름은 절대 보내지 않습니다.", + "crash_label": "충돌 보고서 보내기", + "crash_description": "앱 오류는 앱 자체 코드의 파일 이름과 정리된 짧은 메시지와 함께 전송됩니다. 앱 자체가 충돌하면 충돌 파일이 이 컴퓨터에 남고, 직접 보낼 때만 밖으로 나갑니다.", + "crash_memory": "충돌 파일은 충돌 당시 앱 메모리 일부의 복사본이므로 열려 있던 메일의 일부가 포함될 수 있습니다. Bulwark는 찾아낸 사용자 이름, 주소, 로그인 토큰을 제거하지만 모두 찾는다고 약속할 수는 없습니다.", + "instance_note": "Bulwark 서버에서는 페이지가 서버에 무엇을 보고할지 해당 서버의 관리자가 별도로 정합니다.", + "forced_off": "조직에서 이 기능을 껐습니다.", + "url_label": "충돌 파일을 보낼 곳", + "url_description": "충돌 파일을 받는 https 주소입니다. Bulwark에는 기본 주소가 없습니다.", + "url_invalid": "전체 https 주소를 입력하세요.", + "url_save": "저장", + "files_label": "이 컴퓨터의 충돌 파일", + "files_description": "Bulwark는 최신 파일을 보관하고 한 달이 지난 파일은 삭제합니다.", + "files_empty": "충돌 파일이 없습니다.", + "file_size": "{size} KB", + "send": "충돌 보고서 보내기…", + "delete_all": "모두 삭제", + "sent": "충돌 보고서를 보냈습니다.", + "send_no_consent": "먼저 충돌 보고서를 켜세요.", + "send_no_url": "주소가 설정되지 않아 보낼 곳이 없습니다. 위에 입력하세요.", + "send_failed": "충돌 보고서를 보낼 수 없습니다." } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 06ae58e27..4a89d3804 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Jūsu organizācija šajā ierīcē ir izslēgusi spraudņus.", "plugins_allowlist": "Jūsu organizācija šajā ierīcē atļauj tikai noteiktus spraudņus.", "off": "Izslēgts" + }, + "desktopTelemetry": { + "section_title": "Lietošanas dati un avāriju atskaites", + "section_description": "Abi ir izslēgti, līdz tos ieslēdzat, un katram ir savs slēdzis.", + "telemetry_label": "Sūtīt lietošanas datus", + "telemetry_description": "Reizi dienā: lietotnes versija, jūsu operētājsistēma un lietotnes savienojuma veids. Ar savu pasta serveri arī tas, kuras funkcijas ir ieslēgtas, un aptuvens kontu skaits. Nekad vēstules, adreses vai serveru nosaukumi.", + "crash_label": "Sūtīt avāriju atskaites", + "crash_description": "Lietotnes kļūdas tiek sūtītas ar failu nosaukumiem no pašas lietotnes koda un īsiem, attīrītiem ziņojumiem. Ja avarē pati lietotne, avārijas fails paliek šajā datorā un to atstāj tikai tad, ja to nosūtāt.", + "crash_memory": "Avārijas fails ir daļas lietotnes atmiņas kopija avārijas brīdī, tāpēc tajā var būt atvērtu vēstuļu daļas. Bulwark noņem atrastos lietotājvārdus, adreses un pierakstīšanās marķierus, bet nevar apsolīt atrast visu.", + "instance_note": "Bulwark serverī tā administrators atsevišķi izlemj, ko lapas tam ziņo.", + "forced_off": "Jūsu organizācija to ir izslēgusi.", + "url_label": "Kurp nonāk avārijas faili", + "url_description": "Https adrese, kas saņem avārijas failu. Bulwark tādu nenodrošina.", + "url_invalid": "Ievadiet pilnu https adresi.", + "url_save": "Saglabāt", + "files_label": "Avārijas faili šajā datorā", + "files_description": "Bulwark patur jaunākos un dzēš tos, kas vecāki par mēnesi.", + "files_empty": "Nav avārijas failu.", + "file_size": "{size} KB", + "send": "Sūtīt avārijas atskaiti…", + "delete_all": "Dzēst visus", + "sent": "Avārijas atskaite nosūtīta.", + "send_no_consent": "Vispirms ieslēdziet avāriju atskaites.", + "send_no_url": "Adrese nav iestatīta, tāpēc to nav kur sūtīt. Ievadiet to augstāk.", + "send_failed": "Avārijas atskaiti neizdevās nosūtīt." } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 59782e7af..4e954dc90 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Танай байгууллага энэ төхөөрөмж дээр залгаасуудыг унтраасан.", "plugins_allowlist": "Танай байгууллага энэ төхөөрөмж дээр зөвхөн тодорхой залгаасуудыг зөвшөөрдөг.", "off": "Унтраалттай" + }, + "desktopTelemetry": { + "section_title": "Хэрэглээний мэдээлэл ба гэмтлийн тайлан", + "section_description": "Хоёулаа таныг асаах хүртэл унтраалттай бөгөөд тус бүр өөрийн унтраалгатай.", + "telemetry_label": "Хэрэглээний мэдээлэл илгээх", + "telemetry_description": "Өдөрт нэг удаа: аппын хувилбар, таны үйлдлийн систем, апп хэрхэн холбогддог. Өөрийн шуудангийн сервертэй бол мөн ямар боломжууд асаалттай, бүртгэлийн ойролцоо тоо. Захидал, хаяг, серверийн нэрийг хэзээ ч илгээхгүй.", + "crash_label": "Гэмтлийн тайлан илгээх", + "crash_description": "Аппын алдаанууд аппын өөрийн кодын файлын нэрс болон богино, цэвэрлэсэн мессежтэй хамт илгээгдэнэ. Апп өөрөө гэмтвэл гэмтлийн файл энэ компьютерт үлдэх бөгөөд та илгээсэн үед л гарна.", + "crash_memory": "Гэмтлийн файл нь гэмтэх үеийн аппын санах ойн хэсгийн хуулбар тул нээлттэй захидлын хэсгүүдийг агуулж болно. Bulwark олсон хэрэглэгчийн нэр, хаяг, нэвтрэх токенуудыг устгадаг ч бүгдийг олно гэж амлаж чадахгүй.", + "instance_note": "Bulwark сервер дээр хуудсууд түүнд юу мэдээлэхийг админ нь тусад нь шийддэг.", + "forced_off": "Танай байгууллага үүнийг унтраасан.", + "url_label": "Гэмтлийн файлууд хаашаа очих", + "url_description": "Гэмтлийн файлыг хүлээн авах https хаяг. Bulwark-д ийм хаяг байхгүй.", + "url_invalid": "Бүтэн https хаяг оруулна уу.", + "url_save": "Хадгалах", + "files_label": "Энэ компьютер дээрх гэмтлийн файлууд", + "files_description": "Bulwark хамгийн сүүлийнхийг хадгалж, сараас хуучныг устгадаг.", + "files_empty": "Гэмтлийн файл алга.", + "file_size": "{size} КБ", + "send": "Гэмтлийн тайлан илгээх…", + "delete_all": "Бүгдийг устгах", + "sent": "Гэмтлийн тайлан илгээгдлээ.", + "send_no_consent": "Эхлээд гэмтлийн тайланг асаана уу.", + "send_no_url": "Хаяг тохируулаагүй тул илгээх газаргүй. Дээр нэгийг оруулна уу.", + "send_failed": "Гэмтлийн тайланг илгээж чадсангүй." } } diff --git a/locales/nb/common.json b/locales/nb/common.json index 460ed0217..4c76b5736 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Organisasjonen din har slått av programtillegg på denne enheten.", "plugins_allowlist": "Organisasjonen din tillater bare bestemte programtillegg på denne enheten.", "off": "Av" + }, + "desktopTelemetry": { + "section_title": "Bruksdata og krasjrapporter", + "section_description": "Begge er av til du slår dem på, og hver har sin egen bryter.", + "telemetry_label": "Send bruksdata", + "telemetry_description": "Én gang om dagen: appversjonen, operativsystemet ditt og hvordan appen kobler til. Med egen e-postserver også hvilke funksjoner som er på og et omtrentlig antall kontoer. Aldri e-post, adresser eller servernavn.", + "crash_label": "Send krasjrapporter", + "crash_description": "Feil i appen sendes med filnavn fra appens egen kode og korte, rensede meldinger. Hvis selve appen krasjer, blir en krasjfil liggende på denne datamaskinen og forlater den bare hvis du sender den.", + "crash_memory": "En krasjfil er en kopi av en del av appens minne da den krasjet, så den kan inneholde deler av åpen e-post. Bulwark fjerner brukernavn, adresser og påloggingstokener det finner, men kan ikke love å finne alt.", + "instance_note": "På en Bulwark-server bestemmer administratoren separat hva sidene rapporterer til den.", + "forced_off": "Organisasjonen din har slått av dette.", + "url_label": "Hvor krasjfiler sendes", + "url_description": "Https-adressen som mottar en krasjfil. Bulwark har ingen med.", + "url_invalid": "Skriv inn en fullstendig https-adresse.", + "url_save": "Lagre", + "files_label": "Krasjfiler på denne datamaskinen", + "files_description": "Bulwark beholder de nyeste og sletter dem som er eldre enn en måned.", + "files_empty": "Ingen krasjfiler.", + "file_size": "{size} KB", + "send": "Send krasjrapport…", + "delete_all": "Slett alle", + "sent": "Krasjrapport sendt.", + "send_no_consent": "Slå på krasjrapporter først.", + "send_no_url": "Ingen adresse er angitt, så den kan ikke sendes noe sted. Skriv inn en ovenfor.", + "send_failed": "Krasjrapporten kunne ikke sendes." } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 8b260a849..0ead25072 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Je organisatie heeft plug-ins op dit apparaat uitgeschakeld.", "plugins_allowlist": "Je organisatie staat op dit apparaat alleen bepaalde plug-ins toe.", "off": "Uit" + }, + "desktopTelemetry": { + "section_title": "Gebruiksgegevens en crashrapporten", + "section_description": "Beide staan uit totdat je ze aanzet, en elk heeft een eigen schakelaar.", + "telemetry_label": "Gebruiksgegevens verzenden", + "telemetry_description": "Eén keer per dag: de appversie, je besturingssysteem en hoe de app verbinding maakt. Met je eigen mailserver ook welke functies aan staan en een ruw aantal accounts. Nooit e-mail, adressen of servernamen.", + "crash_label": "Crashrapporten verzenden", + "crash_description": "Fouten in de app gaan mee met bestandsnamen uit de eigen code van de app en korte, opgeschoonde meldingen. Als de app zelf crasht, blijft er een crashbestand op deze computer staan dat alleen weggaat als jij het verzendt.", + "crash_memory": "Een crashbestand is een kopie van een deel van het geheugen van de app op het moment van de crash en kan dus delen van geopende e-mail bevatten. Bulwark verwijdert gebruikersnamen, adressen en aanmeldtokens die het vindt, maar kan niet beloven alles te vinden.", + "instance_note": "Op een Bulwark-server bepaalt de beheerder daarvan apart wat de pagina's aan die server melden.", + "forced_off": "Je organisatie heeft dit uitgezet.", + "url_label": "Bestemming van crashbestanden", + "url_description": "Het https-adres dat een crashbestand ontvangt. Bulwark heeft er zelf geen.", + "url_invalid": "Vul een volledig https-adres in.", + "url_save": "Opslaan", + "files_label": "Crashbestanden op deze computer", + "files_description": "Bulwark bewaart de nieuwste en verwijdert bestanden die ouder zijn dan een maand.", + "files_empty": "Geen crashbestanden.", + "file_size": "{size} KB", + "send": "Crashrapport verzenden…", + "delete_all": "Alles verwijderen", + "sent": "Crashrapport verzonden.", + "send_no_consent": "Zet eerst crashrapporten aan.", + "send_no_url": "Er is geen adres ingesteld, dus dit kan nergens heen. Vul hierboven een adres in.", + "send_failed": "Het crashrapport kon niet worden verzonden." } } diff --git a/locales/pl/common.json b/locales/pl/common.json index f8c9161f9..b65708614 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Twoja organizacja wyłączyła wtyczki na tym urządzeniu.", "plugins_allowlist": "Twoja organizacja zezwala na tym urządzeniu tylko na wybrane wtyczki.", "off": "Wyłączone" + }, + "desktopTelemetry": { + "section_title": "Dane o użyciu i raporty o awariach", + "section_description": "Oba są wyłączone, dopóki ich nie włączysz, a każdy ma własny przełącznik.", + "telemetry_label": "Wysyłaj dane o użyciu", + "telemetry_description": "Raz dziennie: wersja aplikacji, system operacyjny i sposób łączenia się aplikacji. Przy własnym serwerze poczty także włączone funkcje i przybliżona liczba kont. Nigdy poczta, adresy ani nazwy serwerów.", + "crash_label": "Wysyłaj raporty o awariach", + "crash_description": "Błędy aplikacji są wysyłane z nazwami plików z kodu samej aplikacji i krótkimi, oczyszczonymi komunikatami. Jeśli sama aplikacja ulegnie awarii, plik awarii zostaje na tym komputerze i opuszcza go tylko wtedy, gdy go wyślesz.", + "crash_memory": "Plik awarii to kopia części pamięci aplikacji z chwili awarii, więc może zawierać fragmenty otwartej poczty. Bulwark usuwa nazwy użytkowników, adresy i tokeny logowania, które znajdzie, ale nie może obiecać, że znajdzie wszystko.", + "instance_note": "Na serwerze Bulwark jego administrator osobno decyduje, co strony mu zgłaszają.", + "forced_off": "Twoja organizacja to wyłączyła.", + "url_label": "Dokąd trafiają pliki awarii", + "url_description": "Adres https, który odbiera plik awarii. Bulwark nie ma własnego.", + "url_invalid": "Wpisz pełny adres https.", + "url_save": "Zapisz", + "files_label": "Pliki awarii na tym komputerze", + "files_description": "Bulwark zachowuje kilka najnowszych i usuwa starsze niż miesiąc.", + "files_empty": "Brak plików awarii.", + "file_size": "{size} KB", + "send": "Wyślij raport o awarii…", + "delete_all": "Usuń wszystkie", + "sent": "Raport o awarii wysłany.", + "send_no_consent": "Najpierw włącz raporty o awariach.", + "send_no_url": "Nie ustawiono adresu, więc nie ma dokąd tego wysłać. Wpisz go powyżej.", + "send_failed": "Nie udało się wysłać raportu o awarii." } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 0b2a251fc..36246a5c7 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "A sua organização desativou os plugins neste dispositivo.", "plugins_allowlist": "A sua organização só permite determinados plugins neste dispositivo.", "off": "Desativado" + }, + "desktopTelemetry": { + "section_title": "Dados de utilização e relatórios de falhas", + "section_description": "Ambos ficam desligados até os ligar, e cada um tem o seu próprio interruptor.", + "telemetry_label": "Enviar dados de utilização", + "telemetry_description": "Uma vez por dia: a versão da aplicação, o seu sistema operativo e a forma como a aplicação se liga. Com o seu próprio servidor de correio, também que funções estão ativas e um número aproximado de contas. Nunca mensagens, endereços ou nomes de servidor.", + "crash_label": "Enviar relatórios de falhas", + "crash_description": "Os erros da aplicação são enviados com nomes de ficheiros do próprio código da aplicação e mensagens curtas e limpas. Se a própria aplicação falhar, um ficheiro de falha fica neste computador e só sai se o enviar.", + "crash_memory": "Um ficheiro de falha é uma cópia de parte da memória da aplicação no momento da falha, por isso pode conter partes de mensagens abertas. O Bulwark remove os nomes de utilizador, endereços e tokens de início de sessão que encontra, mas não pode prometer encontrar tudo.", + "instance_note": "Num servidor Bulwark, o seu administrador decide separadamente o que as páginas lhe comunicam.", + "forced_off": "A sua organização desligou esta opção.", + "url_label": "Destino dos ficheiros de falha", + "url_description": "O endereço https que recebe um ficheiro de falha. O Bulwark não inclui nenhum.", + "url_invalid": "Introduza um endereço https completo.", + "url_save": "Guardar", + "files_label": "Ficheiros de falha neste computador", + "files_description": "O Bulwark mantém os mais recentes e elimina os que têm mais de um mês.", + "files_empty": "Sem ficheiros de falha.", + "file_size": "{size} KB", + "send": "Enviar relatório de falha…", + "delete_all": "Eliminar tudo", + "sent": "Relatório de falha enviado.", + "send_no_consent": "Ligue primeiro os relatórios de falhas.", + "send_no_url": "Não há nenhum endereço definido, por isso não há para onde enviar. Introduza um acima.", + "send_failed": "Não foi possível enviar o relatório de falha." } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 7186c3627..2ee0994f9 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Organizația ta a dezactivat pluginurile pe acest dispozitiv.", "plugins_allowlist": "Organizația ta permite doar anumite pluginuri pe acest dispozitiv.", "off": "Dezactivat" + }, + "desktopTelemetry": { + "section_title": "Date de utilizare și rapoarte de blocare", + "section_description": "Ambele sunt oprite până le porniți și fiecare are propriul comutator.", + "telemetry_label": "Trimite date de utilizare", + "telemetry_description": "O dată pe zi: versiunea aplicației, sistemul de operare și modul în care se conectează aplicația. Cu propriul server de e-mail, și ce funcții sunt pornite și un număr aproximativ de conturi. Niciodată e-mailuri, adrese sau nume de servere.", + "crash_label": "Trimite rapoarte de blocare", + "crash_description": "Erorile din aplicație pleacă cu nume de fișiere din codul aplicației și mesaje scurte, curățate. Dacă aplicația însăși se blochează, un fișier de blocare rămâne pe acest computer și pleacă doar dacă îl trimiteți.", + "crash_memory": "Un fișier de blocare este o copie a unei părți din memoria aplicației din momentul blocării, deci poate conține părți din e-mailuri deschise. Bulwark elimină numele de utilizator, adresele și tokenurile de autentificare pe care le găsește, dar nu poate promite că găsește totul.", + "instance_note": "Pe un server Bulwark, administratorul acestuia decide separat ce îi raportează paginile.", + "forced_off": "Organizația dvs. a oprit această opțiune.", + "url_label": "Unde ajung fișierele de blocare", + "url_description": "Adresa https care primește un fișier de blocare. Bulwark nu vine cu una.", + "url_invalid": "Introduceți o adresă https completă.", + "url_save": "Salvează", + "files_label": "Fișiere de blocare pe acest computer", + "files_description": "Bulwark le păstrează pe cele mai noi și le șterge pe cele mai vechi de o lună.", + "files_empty": "Nu există fișiere de blocare.", + "file_size": "{size} KB", + "send": "Trimite raportul de blocare…", + "delete_all": "Șterge tot", + "sent": "Raport de blocare trimis.", + "send_no_consent": "Porniți mai întâi rapoartele de blocare.", + "send_no_url": "Nu este setată nicio adresă, deci nu există unde să fie trimis. Introduceți una mai sus.", + "send_failed": "Raportul de blocare nu a putut fi trimis." } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 05b7baaab..f0a87b89e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Ваша организация отключила плагины на этом устройстве.", "plugins_allowlist": "Ваша организация разрешает на этом устройстве только некоторые плагины.", "off": "Выкл." + }, + "desktopTelemetry": { + "section_title": "Данные об использовании и отчёты о сбоях", + "section_description": "Оба параметра выключены, пока вы их не включите, и у каждого свой переключатель.", + "telemetry_label": "Отправлять данные об использовании", + "telemetry_description": "Раз в день: версия приложения, ваша операционная система и способ подключения приложения. При собственном почтовом сервере также включённые функции и примерное число учётных записей. Никогда письма, адреса или имена серверов.", + "crash_label": "Отправлять отчёты о сбоях", + "crash_description": "Ошибки в приложении отправляются с именами файлов из кода самого приложения и короткими очищенными сообщениями. Если сбой происходит в самом приложении, файл сбоя остаётся на этом компьютере и покидает его, только если вы его отправите.", + "crash_memory": "Файл сбоя — это копия части памяти приложения в момент сбоя, поэтому он может содержать части открытых писем. Bulwark удаляет найденные имена пользователей, адреса и токены входа, но не может обещать, что найдёт всё.", + "instance_note": "На сервере Bulwark его администратор отдельно решает, что страницы сообщают серверу.", + "forced_off": "Ваша организация отключила это.", + "url_label": "Куда отправляются файлы сбоев", + "url_description": "Адрес https, который принимает файл сбоя. В Bulwark его нет.", + "url_invalid": "Введите полный адрес https.", + "url_save": "Сохранить", + "files_label": "Файлы сбоев на этом компьютере", + "files_description": "Bulwark хранит самые новые и удаляет те, что старше месяца.", + "files_empty": "Файлов сбоев нет.", + "file_size": "{size} КБ", + "send": "Отправить отчёт о сбое…", + "delete_all": "Удалить все", + "sent": "Отчёт о сбое отправлен.", + "send_no_consent": "Сначала включите отчёты о сбоях.", + "send_no_url": "Адрес не задан, поэтому отправлять некуда. Введите его выше.", + "send_failed": "Не удалось отправить отчёт о сбое." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index a21785e59..857d12608 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Vaša organizácia na tomto zariadení vypla pluginy.", "plugins_allowlist": "Vaša organizácia na tomto zariadení povoľuje len niektoré pluginy.", "off": "Vypnuté" + }, + "desktopTelemetry": { + "section_title": "Údaje o používaní a hlásenia o zlyhaniach", + "section_description": "Oboje je vypnuté, kým to nezapnete, a každé má vlastný prepínač.", + "telemetry_label": "Odosielať údaje o používaní", + "telemetry_description": "Raz denne: verzia aplikácie, váš operačný systém a spôsob pripojenia aplikácie. Pri vlastnom poštovom serveri aj zapnuté funkcie a približný počet účtov. Nikdy pošta, adresy ani názvy serverov.", + "crash_label": "Odosielať hlásenia o zlyhaniach", + "crash_description": "Chyby v aplikácii sa odosielajú s názvami súborov z kódu aplikácie a krátkymi, očistenými správami. Keď zlyhá samotná aplikácia, súbor o zlyhaní zostane v tomto počítači a odíde len vtedy, keď ho odošlete.", + "crash_memory": "Súbor o zlyhaní je kópia časti pamäte aplikácie v okamihu zlyhania, takže môže obsahovať časti otvorenej pošty. Bulwark odstráni používateľské mená, adresy a prihlasovacie tokeny, ktoré nájde, ale nemôže sľúbiť, že nájde všetko.", + "instance_note": "Na serveri Bulwark rozhoduje jeho správca samostatne o tom, čo mu stránky hlásia.", + "forced_off": "Vaša organizácia to vypla.", + "url_label": "Kam idú súbory o zlyhaní", + "url_description": "Adresa https, ktorá prijíma súbor o zlyhaní. Bulwark žiadnu nemá.", + "url_invalid": "Zadajte úplnú adresu https.", + "url_save": "Uložiť", + "files_label": "Súbory o zlyhaní v tomto počítači", + "files_description": "Bulwark ponechá najnovšie a odstráni tie staršie ako mesiac.", + "files_empty": "Žiadne súbory o zlyhaní.", + "file_size": "{size} KB", + "send": "Odoslať hlásenie o zlyhaní…", + "delete_all": "Odstrániť všetko", + "sent": "Hlásenie o zlyhaní odoslané.", + "send_no_consent": "Najprv zapnite hlásenia o zlyhaniach.", + "send_no_url": "Nie je nastavená žiadna adresa, takže to nie je kam odoslať. Zadajte ju vyššie.", + "send_failed": "Hlásenie o zlyhaní sa nepodarilo odoslať." } } diff --git a/locales/th/common.json b/locales/th/common.json index 7b92fca95..9068e2c57 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6639,5 +6639,30 @@ "plugins_off": "องค์กรของคุณปิดปลั๊กอินบนอุปกรณ์นี้", "plugins_allowlist": "องค์กรของคุณอนุญาตเฉพาะปลั๊กอินบางตัวบนอุปกรณ์นี้", "off": "ปิด" + }, + "desktopTelemetry": { + "section_title": "ข้อมูลการใช้งานและรายงานข้อขัดข้อง", + "section_description": "ทั้งสองอย่างปิดอยู่จนกว่าคุณจะเปิด และแต่ละอย่างมีสวิตช์ของตัวเอง", + "telemetry_label": "ส่งข้อมูลการใช้งาน", + "telemetry_description": "วันละครั้ง: เวอร์ชันแอป ระบบปฏิบัติการของคุณ และวิธีที่แอปเชื่อมต่อ หากใช้เซิร์ฟเวอร์อีเมลของคุณเอง จะส่งฟีเจอร์ที่เปิดอยู่และจำนวนบัญชีโดยประมาณด้วย ไม่ส่งอีเมล ที่อยู่ หรือชื่อเซิร์ฟเวอร์เด็ดขาด", + "crash_label": "ส่งรายงานข้อขัดข้อง", + "crash_description": "ข้อผิดพลาดในแอปจะถูกส่งพร้อมชื่อไฟล์จากโค้ดของแอปเองและข้อความสั้นๆ ที่ทำความสะอาดแล้ว หากตัวแอปเองขัดข้อง ไฟล์ข้อขัดข้องจะอยู่บนคอมพิวเตอร์เครื่องนี้และออกไปเมื่อคุณส่งเท่านั้น", + "crash_memory": "ไฟล์ข้อขัดข้องคือสำเนาส่วนหนึ่งของหน่วยความจำแอปในขณะที่ขัดข้อง จึงอาจมีบางส่วนของอีเมลที่เปิดอยู่ Bulwark จะลบชื่อผู้ใช้ ที่อยู่ และโทเค็นการลงชื่อเข้าใช้ที่พบ แต่ไม่รับประกันว่าจะพบทั้งหมด", + "instance_note": "บนเซิร์ฟเวอร์ Bulwark ผู้ดูแลเซิร์ฟเวอร์นั้นจะตัดสินใจแยกต่างหากว่าหน้าเว็บจะรายงานอะไรให้เซิร์ฟเวอร์", + "forced_off": "องค์กรของคุณปิดสิ่งนี้แล้ว", + "url_label": "ปลายทางของไฟล์ข้อขัดข้อง", + "url_description": "ที่อยู่ https ที่รับไฟล์ข้อขัดข้อง Bulwark ไม่มีที่อยู่ให้มาด้วย", + "url_invalid": "ป้อนที่อยู่ https แบบเต็ม", + "url_save": "บันทึก", + "files_label": "ไฟล์ข้อขัดข้องบนคอมพิวเตอร์เครื่องนี้", + "files_description": "Bulwark เก็บไฟล์ล่าสุดไว้และลบไฟล์ที่เก่ากว่าหนึ่งเดือน", + "files_empty": "ไม่มีไฟล์ข้อขัดข้อง", + "file_size": "{size} KB", + "send": "ส่งรายงานข้อขัดข้อง…", + "delete_all": "ลบทั้งหมด", + "sent": "ส่งรายงานข้อขัดข้องแล้ว", + "send_no_consent": "เปิดรายงานข้อขัดข้องก่อน", + "send_no_url": "ไม่ได้ตั้งที่อยู่ จึงไม่มีปลายทางให้ส่ง ป้อนที่อยู่ด้านบน", + "send_failed": "ไม่สามารถส่งรายงานข้อขัดข้องได้" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 2517fb477..3767d78d5 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Kuruluşunuz bu cihazda eklentileri kapattı.", "plugins_allowlist": "Kuruluşunuz bu cihazda yalnızca belirli eklentilere izin veriyor.", "off": "Kapalı" + }, + "desktopTelemetry": { + "section_title": "Kullanım bilgileri ve kilitlenme raporları", + "section_description": "İkisi de siz açana kadar kapalıdır ve her birinin kendi anahtarı vardır.", + "telemetry_label": "Kullanım bilgilerini gönder", + "telemetry_description": "Günde bir kez: uygulama sürümü, işletim sisteminiz ve uygulamanın bağlanma şekli. Kendi posta sunucunuzda ayrıca hangi özelliklerin açık olduğu ve yaklaşık hesap sayısı. Asla e-posta, adres veya sunucu adı gönderilmez.", + "crash_label": "Kilitlenme raporlarını gönder", + "crash_description": "Uygulamadaki hatalar, uygulamanın kendi kodundaki dosya adları ve kısa, temizlenmiş iletilerle gönderilir. Uygulamanın kendisi kilitlenirse bir kilitlenme dosyası bu bilgisayarda kalır ve yalnızca siz gönderirseniz çıkar.", + "crash_memory": "Kilitlenme dosyası, kilitlenme anında uygulama belleğinin bir bölümünün kopyasıdır, bu yüzden açık e-postaların bölümlerini içerebilir. Bulwark bulduğu kullanıcı adlarını, adresleri ve oturum açma belirteçlerini kaldırır ancak her şeyi bulacağına söz veremez.", + "instance_note": "Bir Bulwark sunucusunda, sayfaların ona ne bildireceğine yöneticisi ayrıca karar verir.", + "forced_off": "Kuruluşunuz bunu kapattı.", + "url_label": "Kilitlenme dosyalarının gideceği yer", + "url_description": "Kilitlenme dosyasını alan https adresi. Bulwark bir adresle gelmez.", + "url_invalid": "Tam bir https adresi girin.", + "url_save": "Kaydet", + "files_label": "Bu bilgisayardaki kilitlenme dosyaları", + "files_description": "Bulwark en yenileri saklar ve bir aydan eski olanları siler.", + "files_empty": "Kilitlenme dosyası yok.", + "file_size": "{size} KB", + "send": "Kilitlenme raporunu gönder…", + "delete_all": "Tümünü sil", + "sent": "Kilitlenme raporu gönderildi.", + "send_no_consent": "Önce kilitlenme raporlarını açın.", + "send_no_url": "Adres ayarlanmadı, bu yüzden gönderilecek yer yok. Yukarıya bir adres girin.", + "send_failed": "Kilitlenme raporu gönderilemedi." } } diff --git a/locales/uk/common.json b/locales/uk/common.json index efc829880..37d024f27 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "Ваша організація вимкнула плагіни на цьому пристрої.", "plugins_allowlist": "Ваша організація дозволяє на цьому пристрої лише деякі плагіни.", "off": "Вимк." + }, + "desktopTelemetry": { + "section_title": "Дані про використання та звіти про збої", + "section_description": "Обидва вимкнені, доки ви їх не ввімкнете, і кожен має власний перемикач.", + "telemetry_label": "Надсилати дані про використання", + "telemetry_description": "Раз на день: версія застосунку, ваша операційна система та спосіб підключення застосунку. З власним поштовим сервером також увімкнені функції та приблизна кількість облікових записів. Ніколи листи, адреси чи назви серверів.", + "crash_label": "Надсилати звіти про збої", + "crash_description": "Помилки в застосунку надсилаються з назвами файлів із коду самого застосунку та короткими очищеними повідомленнями. Якщо збій стається в самому застосунку, файл збою залишається на цьому комп'ютері й покидає його, лише якщо ви його надішлете.", + "crash_memory": "Файл збою — це копія частини пам'яті застосунку в момент збою, тому він може містити частини відкритих листів. Bulwark вилучає знайдені імена користувачів, адреси й токени входу, але не може обіцяти, що знайде все.", + "instance_note": "На сервері Bulwark його адміністратор окремо вирішує, що сторінки йому повідомляють.", + "forced_off": "Ваша організація вимкнула це.", + "url_label": "Куди надходять файли збоїв", + "url_description": "Адреса https, яка приймає файл збою. У Bulwark її немає.", + "url_invalid": "Введіть повну адресу https.", + "url_save": "Зберегти", + "files_label": "Файли збоїв на цьому комп'ютері", + "files_description": "Bulwark зберігає найновіші та видаляє ті, що старші за місяць.", + "files_empty": "Файлів збоїв немає.", + "file_size": "{size} КБ", + "send": "Надіслати звіт про збій…", + "delete_all": "Видалити всі", + "sent": "Звіт про збій надіслано.", + "send_no_consent": "Спочатку ввімкніть звіти про збої.", + "send_no_url": "Адресу не задано, тому надсилати нікуди. Введіть її вище.", + "send_failed": "Не вдалося надіслати звіт про збій." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index e57089754..2b1ac5ba6 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6643,5 +6643,30 @@ "plugins_off": "Tổ chức của bạn đã tắt tiện ích trên thiết bị này.", "plugins_allowlist": "Tổ chức của bạn chỉ cho phép một số tiện ích trên thiết bị này.", "off": "Tắt" + }, + "desktopTelemetry": { + "section_title": "Thông tin sử dụng và báo cáo sự cố", + "section_description": "Cả hai đều tắt cho đến khi bạn bật, và mỗi mục có công tắc riêng.", + "telemetry_label": "Gửi thông tin sử dụng", + "telemetry_description": "Mỗi ngày một lần: phiên bản ứng dụng, hệ điều hành của bạn và cách ứng dụng kết nối. Với máy chủ thư riêng, còn có các tính năng đang bật và số tài khoản ước tính. Không bao giờ gửi thư, địa chỉ hay tên máy chủ.", + "crash_label": "Gửi báo cáo sự cố", + "crash_description": "Lỗi trong ứng dụng được gửi kèm tên tệp từ mã của chính ứng dụng và thông báo ngắn đã được làm sạch. Nếu chính ứng dụng gặp sự cố, một tệp sự cố sẽ ở lại trên máy tính này và chỉ rời đi khi bạn gửi.", + "crash_memory": "Tệp sự cố là bản sao một phần bộ nhớ của ứng dụng khi gặp sự cố, nên có thể chứa một phần thư đang mở. Bulwark xóa tên người dùng, địa chỉ và mã đăng nhập mà nó tìm thấy, nhưng không thể hứa tìm được tất cả.", + "instance_note": "Trên máy chủ Bulwark, quản trị viên của máy chủ đó quyết định riêng những gì các trang báo cáo về.", + "forced_off": "Tổ chức của bạn đã tắt mục này.", + "url_label": "Nơi nhận tệp sự cố", + "url_description": "Địa chỉ https nhận tệp sự cố. Bulwark không có sẵn địa chỉ nào.", + "url_invalid": "Nhập địa chỉ https đầy đủ.", + "url_save": "Lưu", + "files_label": "Tệp sự cố trên máy tính này", + "files_description": "Bulwark giữ các tệp mới nhất và xóa các tệp cũ hơn một tháng.", + "files_empty": "Không có tệp sự cố.", + "file_size": "{size} KB", + "send": "Gửi báo cáo sự cố…", + "delete_all": "Xóa tất cả", + "sent": "Đã gửi báo cáo sự cố.", + "send_no_consent": "Hãy bật báo cáo sự cố trước.", + "send_no_url": "Chưa đặt địa chỉ nên không có nơi để gửi. Hãy nhập một địa chỉ ở trên.", + "send_failed": "Không thể gửi báo cáo sự cố." } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index bd69c19d3..5f337c929 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "您的組織已在此裝置上關閉外掛程式。", "plugins_allowlist": "您的組織在此裝置上只允許使用部分外掛程式。", "off": "關閉" + }, + "desktopTelemetry": { + "section_title": "使用資訊與當機報告", + "section_description": "兩者在您開啟前都維持關閉,且各有獨立的開關。", + "telemetry_label": "傳送使用資訊", + "telemetry_description": "每天一次:應用程式版本、您的作業系統以及應用程式的連線方式。使用自己的郵件伺服器時,還包括已開啟的功能和大致的帳號數量。絕不傳送郵件、地址或伺服器名稱。", + "crash_label": "傳送當機報告", + "crash_description": "應用程式中的錯誤會連同應用程式本身程式碼的檔案名稱和經過清理的簡短訊息一起傳送。如果應用程式本身當機,當機檔案會留在這台電腦上,只有您傳送時才會離開。", + "crash_memory": "當機檔案是當機時應用程式部分記憶體的副本,因此可能包含已開啟郵件的部分內容。Bulwark 會移除它找到的使用者名稱、地址和登入權杖,但無法保證找到全部。", + "instance_note": "在 Bulwark 伺服器上,頁面向伺服器回報哪些內容由該伺服器的管理員另行決定。", + "forced_off": "您的組織已關閉此功能。", + "url_label": "當機檔案的去向", + "url_description": "接收當機檔案的 https 地址。Bulwark 沒有預設地址。", + "url_invalid": "請輸入完整的 https 地址。", + "url_save": "儲存", + "files_label": "這台電腦上的當機檔案", + "files_description": "Bulwark 保留最新的檔案,並刪除超過一個月的檔案。", + "files_empty": "沒有當機檔案。", + "file_size": "{size} KB", + "send": "傳送當機報告…", + "delete_all": "全部刪除", + "sent": "當機報告已傳送。", + "send_no_consent": "請先開啟當機報告。", + "send_no_url": "未設定地址,因此無處傳送。請在上方輸入一個。", + "send_failed": "無法傳送當機報告。" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 4356828ad..d160b8053 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6631,5 +6631,30 @@ "plugins_off": "您的组织已在此设备上关闭插件。", "plugins_allowlist": "您的组织在此设备上只允许使用部分插件。", "off": "关闭" + }, + "desktopTelemetry": { + "section_title": "使用信息和崩溃报告", + "section_description": "两者在您开启之前都处于关闭状态,并且各有独立的开关。", + "telemetry_label": "发送使用信息", + "telemetry_description": "每天一次:应用版本、您的操作系统以及应用的连接方式。使用自己的邮件服务器时,还包括已开启的功能和大致的帐户数量。绝不发送邮件、地址或服务器名称。", + "crash_label": "发送崩溃报告", + "crash_description": "应用中的错误会连同应用自身代码的文件名和经过清理的简短消息一起发送。如果应用本身崩溃,崩溃文件会留在这台电脑上,只有您发送时才会离开。", + "crash_memory": "崩溃文件是崩溃时应用部分内存的副本,因此可能包含已打开邮件的部分内容。Bulwark 会删除它找到的用户名、地址和登录令牌,但不能保证找到全部。", + "instance_note": "在 Bulwark 服务器上,页面向服务器报告哪些内容由该服务器的管理员另行决定。", + "forced_off": "您的组织已关闭此功能。", + "url_label": "崩溃文件的去向", + "url_description": "接收崩溃文件的 https 地址。Bulwark 没有预设地址。", + "url_invalid": "请输入完整的 https 地址。", + "url_save": "保存", + "files_label": "这台电脑上的崩溃文件", + "files_description": "Bulwark 保留最新的文件,并删除超过一个月的文件。", + "files_empty": "没有崩溃文件。", + "file_size": "{size} KB", + "send": "发送崩溃报告…", + "delete_all": "全部删除", + "sent": "崩溃报告已发送。", + "send_no_consent": "请先开启崩溃报告。", + "send_no_url": "未设置地址,因此无处发送。请在上方输入一个。", + "send_failed": "无法发送崩溃报告。" } } From a52cd8c82963823a6274e9d58241cab57f25a778 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:57:19 +0100 Subject: [PATCH 33/45] fix: save no empty file when an export could write no message at all --- .../__tests__/local-export.test.tsx | 37 ++++++++++++++++++- .../mail-transfer/local-export-dialog.tsx | 16 +++++++- .../mail-transfer/mail-transfer-host.tsx | 4 +- integration/tests/24-do-export.spec.ts | 31 ++++++++++++---- .../__tests__/local-store-export.test.ts | 7 +++- lib/mail-export/engine.ts | 9 ++++- 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/components/mail-transfer/__tests__/local-export.test.tsx b/components/mail-transfer/__tests__/local-export.test.tsx index 477270d85..3a7f9a5d3 100644 --- a/components/mail-transfer/__tests__/local-export.test.tsx +++ b/components/mail-transfer/__tests__/local-export.test.tsx @@ -17,6 +17,7 @@ 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: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0 }, @@ -25,10 +26,10 @@ const FOLDERS = [ 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 } = {}) { +function fakeStore(overrides: { rawBlob?: (blobId: string) => unknown; status?: { total: number; kept: number } } = {}) { const area = vi.fn(async (_account: string, name: string, params?: { mailboxId?: string; blobId?: string }): Promise => { if (name === 'mail.session') return { primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-a' } }; - if (name === 'mail.rawStatus') return params?.mailboxId === 'inbox' ? { total: 3, kept: 2 } : { total: 1, kept: 1 }; + 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.rawKept') return []; return null; @@ -143,6 +144,22 @@ describe('before an export starts', () => { 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(); + 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')); @@ -284,6 +301,9 @@ describe('what an export couldn\'t write', () => { 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); @@ -306,6 +326,19 @@ describe('what an export couldn\'t write', () => { expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); }); + it('says the export stopped, not that a file is ready, when it ends hidden with nothing written', async () => { + setOnline(false); + notKept(); + const warning = vi.spyOn(toast, 'warning'); + const success = vi.spyOn(toast, 'success'); + render(); + await finishedJob(); + expect(warning).toHaveBeenCalledWith('toast_stopped', expect.anything()); + expect(success).not.toHaveBeenCalled(); + warning.mockRestore(); + success.mockRestore(); + }); + it('offers no retry when everything was written', async () => { render(); act(() => openLocalExport('acc')); diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx index 248fe5cf2..32187ce47 100644 --- a/components/mail-transfer/local-export-dialog.tsx +++ b/components/mail-transfer/local-export-dialog.tsx @@ -70,6 +70,7 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) const progress = job?.kind === "export" ? job.progress : null; const active = isJobActive(job); const stageButton = useRef(null); + const allFolders = useRef(null); useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup"); const inSetup = !progress; @@ -85,6 +86,12 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) 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]); + if (!visible) return null; if (progress) { @@ -135,6 +142,8 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) 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); const onStart = async () => { if (!loaded || starting || chosen.length === 0) return; @@ -176,7 +185,7 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) footer={( <> - + )} > @@ -193,7 +202,10 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) { if (input) input.indeterminate = excluded.size > 0 && excluded.size < rows.length; }} + 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" /> diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx index e98a865bf..51a43b19f 100644 --- a/components/mail-transfer/mail-transfer-host.tsx +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -94,8 +94,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 }); } diff --git a/integration/tests/24-do-export.spec.ts b/integration/tests/24-do-export.spec.ts index cd0f6af91..d4a431151 100644 --- a/integration/tests/24-do-export.spec.ts +++ b/integration/tests/24-do-export.spec.ts @@ -224,7 +224,7 @@ test('with a save picker, it writes straight to the chosen file and no private f await page.evaluate(() => { delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; }); }); -test('with attachments off, offline counts every message and says why; the retry online writes them all', async () => { +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'); @@ -235,18 +235,35 @@ test('with attachments off, offline counts every message and says why; the retry 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 }); + await context.route(`${new URL(JMAP_URL).origin}/**`, (route) => route.abort('internetdisconnected')); + let downloads = 0; + const counted = () => { downloads += 1; }; + page.on('download', counted); await page.getByTestId('local-export-format-mbox').check(); - const download = page.waitForEvent('download', { timeout: 60_000 }); await page.getByTestId('local-export-start').click(); - await download; - await expect(page.getByTestId('local-export-not-kept-attachments')).toContainText(`attachments aren't kept on this device: ${COUNT}`); - 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 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(`${new URL(JMAP_URL).origin}/**`); + // 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: 30_000 }); + 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 }); diff --git a/lib/mail-export/__tests__/local-store-export.test.ts b/lib/mail-export/__tests__/local-store-export.test.ts index ea21f2982..96de1ba46 100644 --- a/lib/mail-export/__tests__/local-store-export.test.ts +++ b/lib/mail-export/__tests__/local-store-export.test.ts @@ -172,12 +172,15 @@ describe('an export from the local store', () => { expect([...(await zipEntries(bytes)).values()]).toEqual([server.files.get('raw-a')]); }); - it('says it is because attachments are off when they are', async () => { + it('says it is because attachments are off when they are, and makes no empty file of nothing', async () => { const server = serverWith({ a: 'inbox', b: 'inbox' }); const { store } = await storeOf(server, { ...SCOPE, attachments: false }); - const { result } = await run(store, 'mbox', ['inbox']); + const { result, bytes } = await run(store, 'eml', ['inbox']); + expect(result.phase).toBe('done'); expect(result.notKept).toEqual({ attachments_off: 2 }); expect(result.exported).toBe(0); + expect(result.file).toBeUndefined(); + expect(bytes.length).toBe(0); }); it('fetches what isn\'t kept when online, one at a time or in a batch, and reads the rest from the store', async () => { diff --git a/lib/mail-export/engine.ts b/lib/mail-export/engine.ts index 802a75672..68453ac67 100644 --- a/lib/mail-export/engine.ts +++ b/lib/mail-export/engine.ts @@ -366,8 +366,13 @@ export async function runExport(options: ExportOptions): Promise } // A cancel with nothing left to fetch must not close and hand out the file. if (signal.aborted) throw cancelled(); - if (zip) await zip.finish(); - progress.file = await sink.close(); + if (progress.exported === 0 && progress.failed > 0) { + // Every message failed: an empty file would only look like a backup. + await sink.abort(); + } else { + if (zip) await zip.finish(); + progress.file = await sink.close(); + } progress.phase = "done"; progress.fraction = 1; } catch (error) { From 3c3f6016b9954bcf8d94e3235da3bddb2db6d416 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:04:14 +0100 Subject: [PATCH 34/45] test: catch a wipe heard twice, a row put back and a mark left by a recovered refusal --- lib/devices/__tests__/registry.test.ts | 16 ++++++++++- lib/offline/__tests__/device-wipe.test.ts | 28 +++++++++++++++++++ .../__tests__/auth-store-device-wipe.test.ts | 14 +++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/lib/devices/__tests__/registry.test.ts b/lib/devices/__tests__/registry.test.ts index 88d2b92ae..f99b41d27 100644 --- a/lib/devices/__tests__/registry.test.ts +++ b/lib/devices/__tests__/registry.test.ts @@ -199,9 +199,23 @@ describe('the rules', () => { expect(await registry.getDevice(waiting.id)).not.toBeNull(); }); + it('prunes the user\'s own rows also in the hour after the registry-wide look', async () => { + const registry = await import('../registry'); + const start = Date.now() + 2 * 365 * 24 * 60 * 60 * 1000; + vi.useFakeTimers({ now: start, toFake: ['Date'] }); + const old = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + // Someone else's registration has the look just before the row goes stale. + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS - 30 * 60 * 1000); + await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS + 60_000); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + vi.useRealTimers(); + expect(await registry.getDevice(old.id)).toBeNull(); + }); + it('abandoned rows expire with no admin looking, others\' too, through any registration', async () => { const registry = await import('../registry'); - const start = Date.now() + 365 * 24 * 60 * 60 * 1000; + const start = Date.now() + 4 * 365 * 24 * 60 * 60 * 1000; vi.useFakeTimers({ now: start, toFake: ['Date'] }); const abandoned = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); const waiting = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index 72570611e..8118687a6 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -505,6 +505,8 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => { expect(state.isEvicted(ALICE)).toBe(false); expect(state.visibleAccounts()).toEqual([ALICE]); expect((await queue.readQueue(ALICE)).outbox.map((row) => row.summary.subject)).toEqual(['Unsent']); + // Signed in: the page says nothing from here on, even before the next look. + expect(wipeState.refusedWaits()).toEqual([]); await wipe.sweepRefusedLogins(); expect(wipeState.readRefusedLogins()).toEqual({}); @@ -717,8 +719,11 @@ describe('other windows', () => { vi.stubGlobal('location', { ...window.location, reload }); useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); state.writeEnabled([ALICE, BOB]); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); const stop = wipe.startDeviceWipe(); const other = new BroadcastChannel('bulwark-device-wipe'); + await vi.waitFor(() => expect(sent.length).toBeGreaterThan(0)); + sent.length = 0; other.postMessage({ account: BOB, phase }); await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined()); @@ -729,6 +734,9 @@ describe('other windows', () => { expect(state.isEvicted(BOB)).toBe(false); expect(store.removed).toEqual([]); expect(purge.files).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); expect(JSON.parse(localStorage.getItem('account-registry') ?? '{"state":{"accounts":[]}}').state.accounts.map((a: { id: string }) => a.id)).not.toContain(BOB); other.close(); @@ -928,10 +936,14 @@ describe('two wipes at once', () => { }); it('a wipe heard twice is done once: the second finds the row gone', async () => { + let cleaned = 0; + registry.onDeviceWipe(() => { cleaned += 1; }); await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW }); + expect(cleaned).toBe(1); const reports = sent.filter((call) => call.url.endsWith('/wiped')).length; expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0 }); await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW }); + expect(cleaned).toBe(1); expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(reports); expect(wipeState.consumeWipeNote()).toBeNull(); expect(wipeState.readPendingWipes()).toEqual({}); @@ -960,6 +972,16 @@ describe('a sign-out', () => { expect(wipeState.readWipeConfirmations()).toEqual([]); }); + it('has its row on the way before the key database goes with the rest', async () => { + respond = () => json(503, {}); + let queuedAtPurge: string[] | null = null; + purge.during = () => { queuedAtPurge ??= wipeState.readWipeConfirmations().map((entry) => entry.id); }; + wipe.registerDeviceWipe(); + await useAuthStore.getState().logout({ endProviderSession: false }); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + expect(queuedAtPurge).toEqual([ROW]); + }); + it('removing another account drops its row too, and keeps this one\'s', async () => { useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); @@ -1098,6 +1120,12 @@ describe('what else goes with a wipe', () => { await wipe.wipeAccountFromDevice(ALICE, 'admin'); expect(closed).toEqual(['From Eve']); expect(unsubscribed).toBe(0); + // No full sign-out comes along to clean up behind it. A store may write itself back, empty. + for (const key of ['pro-tabs', 'search-history-storage', 'files-favorites', 'files-recent-files', 'files-last-parent-id', 'files-path-stack', + 'bulwark-delegate-names', 'calendar-notification-storage', 'webdav-favorites', 'webdav-recent-files', 'webdav-last-path', 'template-storage']) { + expect([key, localStorage.getItem(key)]).not.toEqual([key, 'x']); + } + expect(deletedCaches).toContain('bulwark-push-state-v1'); }); it('a wipe from settings takes them too', async () => { diff --git a/stores/__tests__/auth-store-device-wipe.test.ts b/stores/__tests__/auth-store-device-wipe.test.ts index ec7706b62..1f1d9a00b 100644 --- a/stores/__tests__/auth-store-device-wipe.test.ts +++ b/stores/__tests__/auth-store-device-wipe.test.ts @@ -74,6 +74,14 @@ function account(id: string, slot: number, authMode: 'basic' | 'oauth') { }; } +/** How accounts leave: 'explicit' is what takes their data with it. */ +async function removalReasons(): Promise { + const { onAccountRemoved } = await import('@/stores/account-store'); + const reasons: string[] = []; + onAccountRemoved((_id, reason) => { reasons.push(reason); }); + return reasons; +} + type Answer = (url: string, method: string) => Response | null; let answer: Answer; const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); @@ -165,11 +173,13 @@ describe('a login the server ended, at a start', () => { }); it('invalid_grant fires the hook, for certain, and only hides the data', async () => { + const reasons = await removalReasons(); useOfflineStore.setState({ enabled: [ALICE] }); answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); await useAuthStore.getState().checkAuth(); // With mail kept here the real connect goes on behind the local copy. await vi.waitFor(() => expect(heard).toEqual([ALICE])); + await vi.waitFor(() => expect(reasons).toEqual(['evicted'])); expect(certain).toEqual([true]); expect(isEvicted(ALICE)).toBe(true); expect(useOfflineStore.getState().enabled).toEqual([ALICE]); @@ -218,10 +228,12 @@ describe('a login the server ended, at a switch', () => { }); it('invalid_grant fires the hook and hides the account, keeping its data', async () => { + const reasons = await removalReasons(); useOfflineStore.setState({ enabled: [ALICE, BOB] }); answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); await useAuthStore.getState().switchAccount(BOB); expect(heard).toEqual([BOB]); + expect(reasons).toEqual(['evicted']); expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined(); expect(isEvicted(BOB)).toBe(true); expect(useOfflineStore.getState().enabled).toContain(BOB); @@ -282,7 +294,7 @@ describe('a password the server keeps refusing', () => { it('during the connect: a later failure isn\'t read as a refusal', async () => { clients.connect = async (client) => { - if ((client as { username?: string }).username === 'bob') (client as { refused?: (ms: number) => void }).refused?.(0); + (client as { refused?: (ms: number) => void }).refused?.(0); }; await useAuthStore.getState().checkAuth(); await vi.waitFor(() => expect(clients.made.length).toBeGreaterThanOrEqual(2)); From 3b49b1109eb0972ca0caca8cb50cb0ce26d30214 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:15:02 +0100 Subject: [PATCH 35/45] chore: name who acts on the keystore origins policy, as the shell's copy of the schema does --- lib/desktop/policy-schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index aa62fa720..4a7391957 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -827,7 +827,7 @@ export const POLICY_CONSUMERS: Record = { CrashReportsAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' }, QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' }, AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' }, - KeystoreAllowedOrigins: { pendingConsumer: 'feat/native-helper: setKeystoreAllowedOrigins in src/main/features/keystore.ts' }, + KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' }, }; /** The part the embedded server enforces. Sent to it by message. */ From 791570772cdb14cec6388d26e489261cab5f7653 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:15:16 +0100 Subject: [PATCH 36/45] test: open the sign-in note spec with the suite's browser settings --- integration/tests/24-do-remote-wipe.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/24-do-remote-wipe.spec.ts b/integration/tests/24-do-remote-wipe.spec.ts index 5938e9bbd..e70d36270 100644 --- a/integration/tests/24-do-remote-wipe.spec.ts +++ b/integration/tests/24-do-remote-wipe.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type BrowserContext, type Page } from '@playwright/test'; -import { ENT, ENT_PASSWORD, adminContext, deliver, requireEntEnv, shot, userPage } from './helpers/ent'; +import { ENT, ENT_PASSWORD, adminContext, contextOptions, deliver, requireEntEnv, shot, userPage } from './helpers/ent'; import { JMAP_URL } from './helpers/config'; /** From af6cdfb997fed144245a39414ef4a076d081257f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:36:13 +0100 Subject: [PATCH 37/45] fix: keep unsent mail through the idle and session limits, and mark a signed-out account in the switcher The timers used to delete everything an account kept, unsent mail included. They now follow the refused-login rule: sign out and hide, the next sign-in brings it back, otherwise it goes after 30 days. A login without "Remember me" that ends with the browser, an admin wipe and a wipe from settings don't change. - `wipeOnRefusedLogin` is now `wipeOnForcedSignOut`, since it also covers the limits. The server's `SESSION_TOO_OLD` goes the same way, as `session`. - A refused account beside one that stays signed in shows in the switcher as needing sign-in, with the count and the date, and gets one toast. Existing tests changed because the rule did: the session timer tests expected a plain `logoutAll`, and the auth store tests expected no hook at the session limit. --- app/(main)/[locale]/login/page.tsx | 3 +- .../admin/_pages/device-registry-panel.tsx | 1 + .../org/__tests__/offline-section.test.tsx | 14 +- .../admin/org/sections/offline-section.tsx | 14 +- .../account-switcher-needs-sign-in.test.tsx | 88 +++++++++++ components/layout/account-switcher.tsx | 30 ++++ .../__tests__/device-wipe-host.test.tsx | 62 ++++++++ components/offline/device-wipe-host.tsx | 14 +- .../__tests__/session-timeout-guard.test.tsx | 10 +- components/session/session-timeout-guard.tsx | 6 +- .../server-device-offline-caps.test.ts | 10 +- lib/admin/organisation-policy.ts | 12 +- lib/admin/server-device-policy.ts | 2 +- lib/devices/types.ts | 8 +- lib/devices/wipe-state.ts | 83 ++++++++++- lib/offline/__tests__/device-wipe.test.ts | 137 +++++++++++++++++- lib/offline/credentials-rejected.ts | 23 +-- lib/offline/device-wipe.ts | 34 +++-- locales/ar/common.json | 9 +- locales/ca/common.json | 9 +- locales/cs/common.json | 9 +- locales/da/common.json | 9 +- locales/de/common.json | 9 +- locales/en/common.json | 9 +- locales/es/common.json | 9 +- locales/et/common.json | 9 +- locales/fa/common.json | 9 +- locales/fr/common.json | 9 +- locales/he/common.json | 9 +- locales/hu/common.json | 9 +- locales/it/common.json | 9 +- locales/ja/common.json | 9 +- locales/ko/common.json | 9 +- locales/lv/common.json | 9 +- locales/mn/common.json | 9 +- locales/nb/common.json | 9 +- locales/nl/common.json | 9 +- locales/pl/common.json | 9 +- locales/pt/common.json | 9 +- locales/ro/common.json | 9 +- locales/ru/common.json | 9 +- locales/sk/common.json | 9 +- locales/th/common.json | 9 +- locales/tr/common.json | 9 +- locales/uk/common.json | 9 +- locales/vi/common.json | 9 +- locales/zh-TW/common.json | 9 +- locales/zh/common.json | 9 +- .../__tests__/auth-store-device-wipe.test.ts | 43 +++++- stores/auth-store.ts | 76 ++++++---- 50 files changed, 805 insertions(+), 135 deletions(-) create mode 100644 components/layout/__tests__/account-switcher-needs-sign-in.test.tsx create mode 100644 components/offline/__tests__/device-wipe-host.test.tsx diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index fa1d82e00..927e31df9 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1516,9 +1516,10 @@ function LoginPageContent() { )} {refusedWaiting.map((wait) => { const date = new Date(wait.until).toLocaleDateString(String(params?.locale ?? "en"), { dateStyle: "medium" }); + const prefix = wait.kind === "session" ? "session_waiting" : "refused_waiting"; return (
- {wait.unsent === null ? tOfflineWipe("refused_waiting_unknown", { date }) : tOfflineWipe("refused_waiting", { count: wait.unsent, date })} + {wait.unsent === null ? tOfflineWipe(`${prefix}_unknown`, { date }) : tOfflineWipe(prefix, { count: wait.unsent, date })}
); })} diff --git a/app/(main)/admin/_pages/device-registry-panel.tsx b/app/(main)/admin/_pages/device-registry-panel.tsx index db4ef63fa..eba321350 100644 --- a/app/(main)/admin/_pages/device-registry-panel.tsx +++ b/app/(main)/admin/_pages/device-registry-panel.tsx @@ -235,6 +235,7 @@ function wipeReasonLabel(reason: NonNullable): string switch (reason) { case 'user': return 'by the user'; case 'rejected': return 'the server refused the login'; + case 'session': return 'the session limit ran out'; default: return reason; } } diff --git a/components/admin/org/__tests__/offline-section.test.tsx b/components/admin/org/__tests__/offline-section.test.tsx index dfcbe2415..2b5630630 100644 --- a/components/admin/org/__tests__/offline-section.test.tsx +++ b/components/admin/org/__tests__/offline-section.test.tsx @@ -61,12 +61,18 @@ describe('Offline mode section', () => { it('leaves a refused login\'s data alone by default, and can ask for deletion at once, per scope', async () => { await renderSection(); - const toggle = screen.getByTestId('org-devices-wipeOnRefusedLogin'); + const toggle = screen.getByTestId('org-devices-wipeOnForcedSignOut'); expect(toggle.getAttribute('aria-checked')).toBe('false'); act(() => latest!.setScopeId('field')); - fireEvent.click(screen.getByTestId('org-devices-wipeOnRefusedLogin')); - expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnRefusedLogin: true } }); - expect(latest!.policy?.organisation?.devices.wipeOnRefusedLogin).toBe(false); + fireEvent.click(screen.getByTestId('org-devices-wipeOnForcedSignOut')); + expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnForcedSignOut: true } }); + expect(latest!.policy?.organisation?.devices.wipeOnForcedSignOut).toBe(false); + }); + + it('says what a sign-out does now, for the timers too', async () => { + await renderSection(); + expect(document.body.textContent).not.toContain('Every sign-out, the automatic one after inactivity included, deletes everything'); + expect(document.body.textContent).toContain('A sign-out someone asks for deletes everything'); }); it('writes a scope override only into that scope', async () => { diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index ed51c2708..9a806ccbc 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -50,7 +50,7 @@ const describeCap = (value: number, unit: string) => (value > 0 ? `${value} ${un export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) { const forbid = draft.orgValue('devices', 'noOffline'); const noAttachments = draft.orgValue('devices', 'noOfflineAttachments'); - const wipeOnRefused = draft.orgValue('devices', 'wipeOnRefusedLogin'); + const wipeOnRefused = draft.orgValue('devices', 'wipeOnForcedSignOut'); return (
- draft.setOrgValue('devices', 'wipeOnRefusedLogin', v)} testId="org-devices-wipeOnRefusedLogin" /> - draft.clearOrgValue('devices', 'wipeOnRefusedLogin')} /> + draft.setOrgValue('devices', 'wipeOnForcedSignOut', v)} testId="org-devices-wipeOnForcedSignOut" /> + draft.clearOrgValue('devices', 'wipeOnForcedSignOut')} />

@@ -120,8 +120,8 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) { with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not against someone using the unlocked browser or app. The limits above can only lower what people pick: where several scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to - them when it fetches the policy; the server can't check what is on a device. Every sign-out, the automatic one - after inactivity included, deletes everything. With several accounts signed in, each account follows its own settings. + them when it fetches the policy; the server can't check what is on a device. A sign-out someone asks for deletes everything. + One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings. Bulwark Lite reads the same policy but has no offline mode.{' '} How offline mode works diff --git a/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx new file mode 100644 index 000000000..ffb61683c --- /dev/null +++ b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx @@ -0,0 +1,88 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// A refused account beside another that stays signed in: the switcher marks it, +// with the count and the date, and takes the user to its sign-in. + +const push = vi.hoisted(() => vi.fn()); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push, replace: vi.fn() }), + usePathname: () => '/', +})); + +const { AccountSwitcher } = await import('../account-switcher'); +const { useAccountStore } = await import('@/stores/account-store'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { markLoginRefused, setRefusedCount, dropRefusedLogin } = await import('@/lib/devices/wipe-state'); +const { useOfflineStore, markEvicted, clearEvicted } = await import('@/lib/offline/state'); + +const A = 'a@example.com'; +const B = 'b@example.com'; + +function account(id: string) { + return { id, label: id, email: id, username: id, displayName: id.toUpperCase(), serverUrl: 'https://mail.example.com', avatarColor: '#336699', cookieSlot: 0, isConnected: true, isDefault: id === A }; +} + +function open(): void { + render(); + fireEvent.click(screen.getByTestId('account-switcher')); +} + +beforeEach(() => { + push.mockClear(); + localStorage.clear(); + useAccountStore.setState({ accounts: [account(A)] } as never); + useAuthStore.setState({ activeAccountId: A } as never); + useSettingsStore.setState({ interfaceLayout: 'default' } as never); + useOfflineStore.setState({ enabled: [A, B] }); + markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B }); + markEvicted(B); +}); + +describe('an account that needs signing in again', () => { + it('is marked, with its count and the date its data goes', () => { + setRefusedCount(B, 2); + open(); + const row = screen.getByTestId('account-needs-sign-in'); + expect(row.textContent).toContain(B); + expect(row.textContent).toContain('needs_sign_in'); + expect(row.textContent).toContain('switcher_waiting'); + }); + + it('says only the date when it couldn\'t count, or there\'s nothing unsent', () => { + setRefusedCount(B, 0); + open(); + const row = screen.getByTestId('account-needs-sign-in'); + expect(row.textContent).toContain('switcher_kept'); + expect(row.textContent).not.toContain('switcher_waiting'); + }); + + it('takes the user to its sign-in, with the name filled in', () => { + setRefusedCount(B, 2); + open(); + fireEvent.click(screen.getByTestId('account-needs-sign-in')); + expect(push).toHaveBeenCalledWith(`/login?mode=add-account&username=${encodeURIComponent(B)}`); + }); + + it('shows the count as soon as it\'s known, with the menu open', () => { + open(); + expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_kept'); + act(() => setRefusedCount(B, 3)); + expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_waiting'); + }); + + it('isn\'t shown to an account that wasn\'t there', () => { + useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never); + useAccountStore.setState({ accounts: [account('stranger@example.com')] } as never); + open(); + expect(screen.queryByTestId('account-needs-sign-in')).toBeNull(); + }); + + it('is gone once it signed in again', () => { + clearEvicted(B); + open(); + expect(screen.queryByTestId('account-needs-sign-in')).toBeNull(); + dropRefusedLogin(B); + }); +}); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 505f868e6..9c424eabe 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -17,6 +17,7 @@ import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread"; import { isDocumentRTL } from "@/i18n/direction"; import { cn, formatFileSize } from "@/lib/utils"; import { useRouter } from "@/i18n/navigation"; +import { onRefusedLoginsChanged, siblingWaits, waitDate } from "@/lib/devices/wipe-state"; import { Avatar } from "@/components/ui/avatar"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; @@ -116,6 +117,11 @@ export function AccountSwitcher({ const removeAccount = useAuthStore((s) => s.removeAccount); const logoutAll = useAuthStore((s) => s.logoutAll); const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog(); + const tWipe = useTranslations("offlineWipe"); + // An account that signed out by itself beside this one: its data waits. + const [, noteWaitsChanged] = useState(0); + useEffect(() => onRefusedLoginsChanged(() => noteWaitsChanged((count) => count + 1)), []); + const waiting = siblingWaits(activeAccountId); const updatePosition = useCallback(() => { if (!buttonRef.current) return; @@ -513,6 +519,30 @@ export function AccountSwitcher({

); })} + {waiting.map((wait) => ( + + ))}
{/* Mountain View: the account actions as rows closing the white card, diff --git a/components/offline/__tests__/device-wipe-host.test.tsx b/components/offline/__tests__/device-wipe-host.test.tsx new file mode 100644 index 000000000..f2cf3373e --- /dev/null +++ b/components/offline/__tests__/device-wipe-host.test.tsx @@ -0,0 +1,62 @@ +import { act, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The one toast when an account beside this one signs out by itself. + +const warning = vi.hoisted(() => vi.fn()); +vi.mock('@/stores/toast-store', () => ({ toast: { warning } })); +vi.mock('@/lib/offline/device-wipe', () => ({ startDeviceWipe: () => () => {} })); + +const { DeviceWipeHost } = await import('../device-wipe-host'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { markLoginRefused, setRefusedCount } = await import('@/lib/devices/wipe-state'); +const { useOfflineStore, markEvicted } = await import('@/lib/offline/state'); + +const A = 'a@example.com'; +const B = 'b@example.com'; + +beforeEach(() => { + warning.mockClear(); + localStorage.clear(); + useOfflineStore.setState({ enabled: [A, B] }); + useAuthStore.setState({ activeAccountId: A, isAuthenticated: true } as never); +}); + +function refuseB(): void { + markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B }); + markEvicted(B); +} + +describe('the toast', () => { + it('comes once, when the count is in, and names the account, the count and the date', () => { + render(); + act(() => refuseB()); + expect(warning).not.toHaveBeenCalled(); + act(() => setRefusedCount(B, 2)); + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][0]).toBe('sibling_waiting'); + act(() => setRefusedCount(B, 2)); + expect(warning).toHaveBeenCalledTimes(1); + }); + + it('says the other thing with nothing unsent, or no number', () => { + render(); + act(() => { refuseB(); setRefusedCount(B, null); }); + expect(warning.mock.calls[0][0]).toBe('sibling_kept'); + }); + + it('isn\'t for someone signed in later', () => { + useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never); + refuseB(); + setRefusedCount(B, 2); + render(); + expect(warning).not.toHaveBeenCalled(); + }); + + it('shows at the next page for the one it was meant for, if it got no chance before', () => { + refuseB(); + setRefusedCount(B, 2); + render(); + expect(warning).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/offline/device-wipe-host.tsx b/components/offline/device-wipe-host.tsx index 3816ca38d..405e94a82 100644 --- a/components/offline/device-wipe-host.tsx +++ b/components/offline/device-wipe-host.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { useTranslations } from 'next-intl'; -import { consumeWipeNote, type WipeNote } from '@/lib/devices/wipe-state'; +import { consumeWipeNote, onRefusedLoginsChanged, takeSiblingToasts, waitDate, type WipeNote } from '@/lib/devices/wipe-state'; import { startDeviceWipe } from '@/lib/offline/device-wipe'; import { useAuthStore } from '@/stores/auth-store'; import { toast } from '@/stores/toast-store'; @@ -33,5 +33,17 @@ export function DeviceWipeHost() { if (note) toast.warning(wipeNoteText(note, tRef.current), { duration: 0 }); }, [signedIn]); + // An account beside this one signed out by itself: once, when it's known what waits. + useEffect(() => { + const look = () => { + for (const wait of takeSiblingToasts(useAuthStore.getState().activeAccountId)) { + const values = { name: wait.label, date: waitDate(wait.until) }; + toast.warning(wait.unsent ? tRef.current('sibling_waiting', { ...values, count: wait.unsent }) : tRef.current('sibling_kept', values), { duration: 0 }); + } + }; + look(); + return onRefusedLoginsChanged(look); + }, []); + return null; } diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx index cad5348f1..887ccbf2a 100644 --- a/components/session/__tests__/session-timeout-guard.test.tsx +++ b/components/session/__tests__/session-timeout-guard.test.tsx @@ -184,7 +184,7 @@ describe('session timeout guard', () => { await advance(500); expect(order).toEqual(['draft saved', 'signed out']); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 }); }); @@ -196,7 +196,7 @@ describe('session timeout guard', () => { await advance(DRAFT_FLUSH_TIMEOUT_MS - 1); expect(logoutAll).not.toHaveBeenCalled(); await advance(1); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); }); it('uses the strictest of the signed-in accounts', async () => { @@ -258,7 +258,7 @@ describe('session timeout guard', () => { expect(screen.queryByText('max_title')).toBeNull(); await advance(60 * SECOND); await advance(0); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 }); }); @@ -276,7 +276,7 @@ describe('session timeout guard', () => { fireEvent.pointerDown(window); await advance(0); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 }); }); @@ -319,7 +319,7 @@ describe('session timeout guard', () => { // Later in the body than the overlay, so it paints on top at a higher z-index too. expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); await advance(30 * SECOND); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); } finally { overlay.remove(); hasFocus.mockRestore(); diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx index 08b0dd983..c208a810a 100644 --- a/components/session/session-timeout-guard.tsx +++ b/components/session/session-timeout-guard.tsx @@ -92,7 +92,8 @@ const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: n * (policy.json `organisation.session`). Activity in any Bulwark tab keeps * every tab signed in; a warning counts down first; at zero the open and * minimised composers save their drafts and every account signs out, with - * the reason on the login page. + * the reason on the login page. What the accounts keep on the device hides until + * the next sign-in, unless the organisation asks for deletion at once. */ export function SessionTimeoutGuard() { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -149,7 +150,8 @@ export function SessionTimeoutGuard() { rememberSessionEnd(endReason(kind, current, false)); const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS); if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true)); - await useAuthStore.getState().logoutAll({ endProviderSession: false }); + // Nobody asked: the mail hides until the next sign-in, and unsent messages with it. + await useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); }, []); // The clock, while there is a limit to count. diff --git a/lib/admin/__tests__/server-device-offline-caps.test.ts b/lib/admin/__tests__/server-device-offline-caps.test.ts index 1b9ba98e4..1364b243b 100644 --- a/lib/admin/__tests__/server-device-offline-caps.test.ts +++ b/lib/admin/__tests__/server-device-offline-caps.test.ts @@ -7,15 +7,15 @@ import { sanitizeOrganisationPartial } from '../organisation-policy'; const scope = (id: string, devices: Record) => ({ id, label: id, match: { domains: [`${id}.example.org`] }, policy: { organisation: { devices } } }); -describe('wipeOnRefusedLogin', () => { +describe('wipeOnForcedSignOut', () => { it('is off unless asked for, and survives the sanitizer', () => { - expect(DEFAULT_POLICY.organisation?.devices?.wipeOnRefusedLogin ?? false).toBe(false); - expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: true } }).devices).toMatchObject({ wipeOnRefusedLogin: true }); - expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: 'yes' } }).devices?.wipeOnRefusedLogin).not.toBe(true); + expect(DEFAULT_POLICY.organisation?.devices?.wipeOnForcedSignOut ?? false).toBe(false); + expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: true } }).devices).toMatchObject({ wipeOnForcedSignOut: true }); + expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: 'yes' } }).devices?.wipeOnForcedSignOut).not.toBe(true); }); it('isn\'t set by the stand-in for a login nobody can read: that\'s for what stays on the server', () => { - expect(ALL_DEVICE_LIMITS.wipeOnRefusedLogin).toBe(false); + expect(ALL_DEVICE_LIMITS.wipeOnForcedSignOut).toBe(false); }); }); diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index e8e6f5d4f..0fe813574 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -176,11 +176,11 @@ export interface OrganisationPolicy { /** All attachments and files of one account on a device, in MB. 0 is no cap. */ offlineMaxTotalMb: number; /** - * A device deletes an account's offline data as soon as the server refuses its - * login, instead of hiding it for 30 days until the new password. Unsent mail - * goes with it. + * A device deletes an account's offline data as soon as it signs out with nobody + * asking (the server refused the login, or the idle or session limit ran out), + * instead of hiding it for 30 days until the next sign-in. Unsent mail goes with it. */ - wipeOnRefusedLogin: boolean; + wipeOnForcedSignOut: boolean; }; calendars: { /** Adds up across scopes by id. */ @@ -206,7 +206,7 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = { noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false, pushWithoutContent: false, watermark: false, noOffline: false, offlineMaxDays: 0, noOfflineAttachments: false, offlineMaxAttachmentMb: 0, offlineMaxTotalMb: 0, - wipeOnRefusedLogin: false, + wipeOnForcedSignOut: false, }, calendars: { feeds: [], freeBusyGroup: '' }, }; @@ -551,7 +551,7 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial [key, typeof off === 'number' ? 1 : true]), ), - wipeOnRefusedLogin: false, + wipeOnForcedSignOut: false, } as ServerDevicePolicy; /** diff --git a/lib/devices/types.ts b/lib/devices/types.ts index 367b6bef7..f4e5facf7 100644 --- a/lib/devices/types.ts +++ b/lib/devices/types.ts @@ -3,8 +3,12 @@ * admin and what a device says about its own wipe. No secrets, no store. */ -/** Who asked for a wipe. `rejected`: the server refused the stored login for good. */ -export const WIPE_REASONS = ['admin', 'user', 'rejected'] as const; +/** + * Who asked for a wipe. `rejected`: the server refused the stored login, and + * `session`: the idle or session limit ran out, both where the organisation asks + * for deletion at once. + */ +export const WIPE_REASONS = ['admin', 'user', 'rejected', 'session'] as const; export type WipeReason = (typeof WIPE_REASONS)[number]; export interface DeviceView { diff --git a/lib/devices/wipe-state.ts b/lib/devices/wipe-state.ts index 2b8d9b749..5e4e97f82 100644 --- a/lib/devices/wipe-state.ts +++ b/lib/devices/wipe-state.ts @@ -4,6 +4,7 @@ * is a count, a row id and the secret for that one row. */ +import type { ForcedSignOutKind } from '@/lib/offline/credentials-rejected'; import { EVICTED_KEEP_MS, readEvicted } from '@/lib/offline/state'; import type { WipeReason } from './types'; @@ -107,7 +108,7 @@ export function consumeWipeNote(): WipeNote | null { return note.unknown ? { unsent: note.unsent, unknown: true } : { unsent: note.unsent }; } -// ---- A refused login: what waits for the new password. A count, never subjects ---- +// ---- A login ended with nobody asking: what waits for the next sign-in. A count, never subjects ---- export interface RefusedLogin { at: number; @@ -115,6 +116,23 @@ export interface RefusedLogin { unsent: number | null; /** Not one bad answer at a start: what a deletion the organisation asked for needs. */ certain: boolean; + /** Absent: `refused`. */ + kind?: ForcedSignOutKind; + /** The count has been asked for, whatever came back. */ + counted?: boolean; + /** Accounts signed in beside it at the time: they may see its name, and nobody else does. */ + seenBy?: string[]; + label?: string; + username?: string; + toasted?: boolean; +} + +export interface RefusedLoginMark { + certain?: boolean; + kind?: ForcedSignOutKind; + seenBy?: string[]; + label?: string; + username?: string; } export function readRefusedLogins(): Record { @@ -134,14 +152,24 @@ function writeRefused(refused: Record): void { for (const listener of [...refusedListeners]) listener(); } -export function markLoginRefused(account: string, certain = true, now = Date.now()): void { +export function markLoginRefused(account: string, mark: RefusedLoginMark = {}, now = Date.now()): void { const before = readRefusedLogins(); - writeRefused({ ...before, [account]: { at: before[account]?.at ?? now, unsent: null, certain: certain || before[account]?.certain === true } }); + const { certain = true, kind = 'refused', seenBy, label, username } = mark; + writeRefused({ + ...before, + [account]: { + at: before[account]?.at ?? now, + unsent: null, + certain: certain || before[account]?.certain === true, + kind, + ...(seenBy?.length ? { seenBy, label, username } : {}), + }, + }); } export function setRefusedCount(account: string, unsent: number | null): void { const before = readRefusedLogins(); - if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent } }); + if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent, counted: true } }); } export function dropRefusedLogin(account: string): void { @@ -155,6 +183,7 @@ export interface RefusedWait { unsent: number | null; /** When the data goes if nobody signs in. */ until: number; + kind: ForcedSignOutKind; } /** What the sign-in page says. Numbers only: whoever reads it may not be the person it's about. */ @@ -162,7 +191,51 @@ export function refusedWaits(): RefusedWait[] { const evicted = readEvicted(); return Object.entries(readRefusedLogins()) .filter(([account, wait]) => account in evicted && wait.unsent !== 0) - .map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS })); + .map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS, kind: wait.kind ?? 'refused' })); +} + +export interface SiblingWait extends RefusedWait { + account: string; + label: string; + username: string; +} + +function asSiblingWait(account: string, wait: RefusedLogin, evictedAt: number): SiblingWait { + return { + account, + label: wait.label ?? account, + username: wait.username ?? '', + unsent: wait.unsent, + until: evictedAt + EVICTED_KEEP_MS, + kind: wait.kind ?? 'refused', + }; +} + +/** For the account switcher: accounts that left while `active` was signed in beside them. Nobody else sees their names. */ +export function siblingWaits(active: string | null | undefined): SiblingWait[] { + if (!active) return []; + const evicted = readEvicted(); + return Object.entries(readRefusedLogins()) + .filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active)) + .map(([account, wait]) => asSiblingWait(account, wait, evicted[account])); +} + +/** Once each: what the toast says when such an account leaves. Waits for the count, so it can say it. */ +export function takeSiblingToasts(active: string | null | undefined): SiblingWait[] { + if (!active) return []; + const evicted = readEvicted(); + const refused = readRefusedLogins(); + const due = Object.entries(refused) + .filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active) && wait.counted && !wait.toasted); + if (due.length === 0) return []; + writeRefused({ ...refused, ...Object.fromEntries(due.map(([account, wait]) => [account, { ...wait, toasted: true }])) }); + return due.map(([account, wait]) => asSiblingWait(account, wait, evicted[account])); +} + +/** A date for these notes, in the page's language. */ +export function waitDate(until: number): string { + const language = typeof document === 'undefined' ? undefined : document.documentElement.lang || undefined; + return new Date(until).toLocaleDateString(language, { dateStyle: 'medium' }); } // ---- Plugin databases a running frame kept open: tried again at the next load ---- diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index 8118687a6..96a0369b2 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -447,14 +447,14 @@ describe('check-ins while running', () => { /** What the auth store does around the hook. */ function refuse(account = ALICE, certain = true): void { - wipeState.markLoginRefused(account, certain); + wipeState.markLoginRefused(account, { certain }); rejected.noteCredentialsRejected(account, certain); useAccountStore.getState().removeAccount(account, 'evicted'); } async function policyAsksForDeletion(): Promise { const { usePolicyStore } = await import('@/stores/policy-store'); - const policy = { organisation: { devices: { wipeOnRefusedLogin: true } } }; + const policy = { organisation: { devices: { wipeOnForcedSignOut: true } } }; usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true, source: 'server', partial: false, resolveFor: () => policy } as never); } @@ -496,7 +496,7 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => { // What the sign-in page says, in numbers only. const [wait] = wipeState.refusedWaits(); - expect(wait).toEqual({ unsent: 1, until: expect.any(Number) }); + expect(wait).toEqual({ unsent: 1, until: expect.any(Number), kind: 'refused' }); expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS); expect(wait.until).toBeLessThanOrEqual(at + state.EVICTED_KEEP_MS + 60_000); expect(JSON.stringify({ ...localStorage })).not.toContain('"Unsent"'); @@ -682,15 +682,16 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => { expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); }); - it('the session limit stays out of it: no hook, and the data waits', async () => { + it('the session limit hides too, as what it is, not as a refusal', async () => { wipe.registerDeviceWipe(); token(() => json(401, { error: 'session_expired', maxHours: 8 })); await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); - expect(heard).toEqual([]); + expect(heard).toEqual([ALICE]); await vi.waitFor(() => expect(state.isEvicted(ALICE)).toBe(true)); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); expect(purge.files).toBe(0); expect(store.removed).toEqual([]); - expect(wipeState.readRefusedLogins()).toEqual({}); + expect(wipeState.readRefusedLogins()[ALICE]).toMatchObject({ kind: 'session' }); }); }); }); @@ -1146,3 +1147,127 @@ describe('what else goes with a wipe', () => { expect(unsubscribed).toBe(0); }); }); + +describe('the session timers', () => { + const timerSignOut = () => useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); + + it('leave the outbox whole, and a sign-in brings it back', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + const lost = await import('../lost'); + const at = Date.now(); + await timerSignOut(); + + expect(useAccountStore.getState().accounts).toEqual([]); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(state.isEvicted(ALICE)).toBe(true); + expect(state.visibleAccounts()).toEqual([]); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(lost.consumeLost()).toEqual([]); + await vi.waitFor(() => expect(wipeState.refusedWaits()).toEqual([{ unsent: 1, until: expect.any(Number), kind: 'session' }])); + + const offline = await import('../offline-mode'); + await offline.sweepAccountsGone(at + 14 * DAY); + expect(purge.files).toBe(0); + + useAccountStore.getState().addAccount(entry(ALICE, 0) as never); + expect(state.visibleAccounts()).toEqual([ALICE]); + expect((await queue.readQueue(ALICE)).outbox.map((row) => row.summary.subject)).toEqual(['Unsent']); + expect(wipeState.refusedWaits()).toEqual([]); + await wipe.sweepRefusedLogins(); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); + + it('with no sign-in for 30 days it goes', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + const at = Date.now(); + await timerSignOut(); + const offline = await import('../offline-mode'); + await offline.sweepAccountsGone(at + 31 * DAY); + expect(purge.files).toBeGreaterThan(0); + }); + + it('keep S/MIME and OpenPGP keys and the row: an admin can still reach the device', async () => { + await openDb('smime-plugin-store'); + wipe.registerDeviceWipe(); + await timerSignOut(); + expect(await databases()).toContain('smime-plugin-store'); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + }); + + it('with the organisation asking for deletion, it goes at once, only that account\'s, named as the session limit', async () => { + await policyAsksForDeletion(); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + await openDb('smime-plugin-store'); + wipe.registerDeviceWipe(); + await timerSignOut(); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'session', id: ROW })); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + expect(state.useOfflineStore.getState().enabled).toEqual([]); + expect(await databases()).toContain('smime-plugin-store'); + }); + + it('a sign-out the user asks for still deletes', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + await useAuthStore.getState().logoutAll({ endProviderSession: false }); + expect(state.isEvicted(ALICE)).toBe(false); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); +}); + +describe('a refused account with another still signed in', () => { + beforeEach(async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await queue.queueSend(BOB, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + }); + + /** What the auth store writes down, with who was there to read it. */ + function refuseBob(): void { + wipeState.markLoginRefused(BOB, { certain: true, seenBy: [ALICE], label: BOB, username: 'bob' }); + rejected.noteCredentialsRejected(BOB, true); + useAccountStore.getState().removeAccount(BOB, 'evicted'); + } + + it('is there for the one who was signed in beside it, with its count and date', async () => { + wipe.registerDeviceWipe(); + const at = Date.now(); + refuseBob(); + await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)[0]?.unsent).toBe(1)); + const [wait] = wipeState.siblingWaits(ALICE); + expect(wait).toMatchObject({ label: BOB, username: 'bob', kind: 'refused' }); + expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS); + }); + + it('isn\'t for anyone who signs in here later, and goes when the account is back', async () => { + wipe.registerDeviceWipe(); + refuseBob(); + await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)).toHaveLength(1)); + expect(wipeState.siblingWaits('someone-else')).toEqual([]); + useAccountStore.getState().addAccount(entry(BOB, 1) as never); + expect(wipeState.siblingWaits(ALICE)).toEqual([]); + }); + + it('says so once, when the count is known', async () => { + wipe.registerDeviceWipe(); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]); + refuseBob(); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]); + await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)[0]?.unsent).toBe(1)); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([expect.objectContaining({ label: BOB, unsent: 1 })]); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]); + }); + + it('says so with no number when the store can\'t give one', async () => { + store.failArea = () => true; + wipe.registerDeviceWipe(); + refuseBob(); + await vi.waitFor(() => expect(wipeState.takeSiblingToasts(ALICE)).toEqual([expect.objectContaining({ unsent: null })])); + }); +}); diff --git a/lib/offline/credentials-rejected.ts b/lib/offline/credentials-rejected.ts index 8a4162723..4fb1388f4 100644 --- a/lib/offline/credentials-rejected.ts +++ b/lib/offline/credentials-rejected.ts @@ -1,14 +1,17 @@ /** - * The server refused this login: `invalid_grant`, a lasting 401 after a fresh try - * (both `certain`), or a 401 at a connect that then failed (not, it's one answer). - * The auth store follows with a sign-out that hides the account's data, which - * waits for the new password. The organisation's session limit, an outage or any - * other refusal doesn't come here. lib/offline/device-wipe.ts listens, to keep the - * count for the sign-in page and to delete at once where the organisation asks for - * that. Free of the stores, so the auth store can call it. + * A login ended with nobody asking. `refused`: the server refused it, by + * `invalid_grant` or a lasting 401 after a fresh try (both `certain`), or a 401 at a + * connect that then failed (not, it's one answer). `session`: the organisation's + * idle or session limit. The auth store follows with a sign-out that hides the + * account's data, which waits for the next sign-in. An outage or any other refusal + * doesn't come here. lib/offline/device-wipe.ts listens, to keep the count for the + * sign-in page and to delete at once where the organisation asks for that. Free of + * the stores, so the auth store can call it. */ -type Listener = (accountId: string, certain: boolean) => void; +export type ForcedSignOutKind = 'refused' | 'session'; + +type Listener = (accountId: string, certain: boolean, kind: ForcedSignOutKind) => void; const listeners = new Set(); @@ -17,10 +20,10 @@ export function onCredentialsRejected(listener: Listener): () => void { return () => listeners.delete(listener); } -export function noteCredentialsRejected(accountId: string, certain = true): void { +export function noteCredentialsRejected(accountId: string, certain = true, kind: ForcedSignOutKind = 'refused'): void { for (const listener of [...listeners]) { try { - listener(accountId, certain); + listener(accountId, certain, kind); } catch { // A sign-out must go on whatever a listener does. } diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index a1989aa44..c63c13cc6 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -5,11 +5,11 @@ * - the instance's registry (lib/devices): a wipe an admin asked for, heard at * check-in. At a start the check-in runs before the local copy is shown. * - the user, from settings. - * - the organisation, when it asks for deletion at once after a refused login - * (`organisation.devices.wipeOnRefusedLogin`). + * - the organisation, when it asks for deletion at once after a refused login or + * an idle or session limit (`organisation.devices.wipeOnForcedSignOut`). * - * A refused login on its own deletes nothing. The account signs out and its data - * hides until someone signs in with the new password, or for 30 days + * A refused login or an idle or session limit on its own deletes nothing. The + * account signs out and its data hides until the next sign-in, or for 30 days * (EVICTED_KEEP_MS). What this file keeps for it is a number, for the sign-in page. * The usual cause is a password changed on another device, and deleting there * would lose unsent mail for very little protection against a thief. A deletion @@ -83,7 +83,7 @@ import { import { onAccountRemoved, useAccountStore, type AccountEntry } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { usePolicyStore } from '@/stores/policy-store'; -import { onCredentialsRejected } from './credentials-rejected'; +import { onCredentialsRejected, type ForcedSignOutKind } from './credentials-rejected'; import { forgetEvictedSecrets } from './evicted-secrets'; import { confirmLoss, registerOfflineHooks, unsentForWipe, wipeAccount, wipeEverything } from './offline-mode'; import { clearEvicted, forgetMailSnapshot, isEvicted, readEvicted, serverKey, useOfflineStore } from './state'; @@ -385,7 +385,7 @@ async function eraseAccount(account: string, reason: WipeReason, options: WipeAc tellOtherWindows(account, 'start'); registerOfflineHooks(); - const ownOnly = reason === 'rejected'; + const ownOnly = reason === 'rejected' || reason === 'session'; const unsent = await unsentForWipe(account); if (record && !readWipeConfirmations().some((entry) => entry.id === record.id)) { queueWipeConfirmation({ account, id: record.id, secret: record.secret, reason, unsent }); @@ -604,14 +604,16 @@ async function countRefused(account: string): Promise { setRefusedCount(account, await unsentForWipe(account)); } +const wipeReasonFor = (kind: ForcedSignOutKind | undefined): WipeReason => (kind === 'session' ? 'session' : 'rejected'); + /** - * The login was refused, and the auth store is signing it out and hiding the data. - * It goes now only if the organisation asked for that, and only for a refusal that - * is one for certain: a single bad answer at a start can be a server in trouble. + * The login ended with nobody asking, and the auth store is signing it out and + * hiding the data. It goes now only if the organisation asked for that, and only + * when it's certain: a single bad answer at a start can be a server in trouble. */ -async function heardRefusal(account: string, certain: boolean): Promise { - if (certain && policyKnown() && devicesForAccount(account).wipeOnRefusedLogin) { - await wipeAccountFromDevice(account, 'rejected'); +async function heardRefusal(account: string, certain: boolean, kind: ForcedSignOutKind): Promise { + if (certain && policyKnown() && devicesForAccount(account).wipeOnForcedSignOut) { + await wipeAccountFromDevice(account, wipeReasonFor(kind)); return; } await countRefused(account); @@ -625,8 +627,8 @@ export async function sweepRefusedLogins(): Promise { continue; } // Nobody's signed in to it, so no groups to go by: what applies to everyone reaches it. - if (wait.certain && policyKnown() && devicesForNoOne().wipeOnRefusedLogin) { - await wipeAccountFromDevice(account, 'rejected'); + if (wait.certain && policyKnown() && devicesForNoOne().wipeOnForcedSignOut) { + await wipeAccountFromDevice(account, wipeReasonFor(wait.kind)); continue; } if (wait.unsent === null) await countRefused(account); @@ -639,8 +641,8 @@ let registered = false; export function registerDeviceWipe(): void { if (registered) return; registered = true; - onCredentialsRejected((account, certain) => { - void heardRefusal(account, certain); + onCredentialsRejected((account, certain, kind) => { + void heardRefusal(account, certain, kind); }); onBeforeSignOut(async (accountId, { keepLocalStore }) => { if (!keepLocalStore) await dropRows(accountId); diff --git a/locales/ar/common.json b/locales/ar/common.json index 94b392c9e..9922f5b33 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "رسائل لم تُرسل تنتظر كلمة مرورك الجديدة: {count}. تبقى حتى {date}.", "refused_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر كلمة مرورك الجديدة. تبقى حتى {date}.", "confirm_unsent_unknown": "ربما توجد هنا رسائل لم تُرسل. ستُفقد.", - "confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز" + "confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز", + "session_waiting": "رسائل لم تُرسل تنتظر تسجيل دخولك التالي: {count}. تبقى حتى {date}.", + "session_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر تسجيل دخولك التالي. تبقى حتى {date}.", + "needs_sign_in": "يلزم تسجيل الدخول", + "switcher_waiting": "رسائل لم تُرسل: {count}. ستُحذف من هذا الجهاز في {date}.", + "switcher_kept": "سيُحذف البريد الموجود على هذا الجهاز في {date}.", + "sibling_waiting": "تم تسجيل خروج {name}. رسائل لم تُرسل: {count}. سجّل الدخول قبل {date} وإلا فستُحذف من هذا الجهاز.", + "sibling_kept": "تم تسجيل خروج {name}. سجّل الدخول قبل {date} وإلا فسيُحذف البريد من هذا الجهاز." }, "offlineStorage": { "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", diff --git a/locales/ca/common.json b/locales/ca/common.json index afb33a7f9..411394a07 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Missatges sense enviar que esperen la teva contrasenya nova: {count}. Es conserven fins al {date}.", "refused_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant la teva contrasenya nova. Es conserven fins al {date}.", "confirm_unsent_unknown": "És possible que hi hagi missatges sense enviar esperant aquí. Es perdran.", - "confirm_templates": "Plantilles de correu desades en aquest dispositiu" + "confirm_templates": "Plantilles de correu desades en aquest dispositiu", + "session_waiting": "Missatges sense enviar que esperen el teu proper inici de sessió: {count}. Es conserven fins al {date}.", + "session_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant el teu proper inici de sessió. Es conserven fins al {date}.", + "needs_sign_in": "Cal iniciar la sessió", + "switcher_waiting": "Missatges sense enviar: {count}. S'eliminaran d'aquest dispositiu el {date}.", + "switcher_kept": "El correu d'aquest dispositiu s'eliminarà el {date}.", + "sibling_waiting": "S'ha tancat la sessió de {name}. Missatges sense enviar: {count}. Inicia la sessió abans del {date} o s'eliminaran d'aquest dispositiu.", + "sibling_kept": "S'ha tancat la sessió de {name}. Inicia la sessió abans del {date} o el correu s'eliminarà d'aquest dispositiu." }, "offlineStorage": { "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest dispositiu. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.", diff --git a/locales/cs/common.json b/locales/cs/common.json index fb07e25a0..53568e499 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Neodeslané zprávy čekající na nové heslo: {count}. Zůstanou do {date}.", "refused_waiting_unknown": "Na nové heslo možná čekají neodeslané zprávy. Zůstanou do {date}.", "confirm_unsent_unknown": "Možná tu čekají neodeslané zprávy. Budou ztraceny.", - "confirm_templates": "Šablony e-mailů uložené v tomto zařízení" + "confirm_templates": "Šablony e-mailů uložené v tomto zařízení", + "session_waiting": "Neodeslané zprávy čekající na další přihlášení: {count}. Zůstanou do {date}.", + "session_waiting_unknown": "Na další přihlášení možná čekají neodeslané zprávy. Zůstanou do {date}.", + "needs_sign_in": "Vyžaduje přihlášení", + "switcher_waiting": "Neodeslané zprávy: {count}. Budou smazány z tohoto zařízení {date}.", + "switcher_kept": "Pošta v tomto zařízení bude smazána {date}.", + "sibling_waiting": "Účet {name} byl odhlášen. Neodeslané zprávy: {count}. Přihlaste se do {date}, jinak budou z tohoto zařízení smazány.", + "sibling_kept": "Účet {name} byl odhlášen. Přihlaste se do {date}, jinak bude pošta z tohoto zařízení smazána." }, "offlineStorage": { "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto zařízení. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", diff --git a/locales/da/common.json b/locales/da/common.json index 554bbdbbe..9cfa5aa0e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Usendte beskeder, der venter på din nye adgangskode: {count}. De bliver til {date}.", "refused_waiting_unknown": "Usendte beskeder venter måske på din nye adgangskode. De bliver til {date}.", "confirm_unsent_unknown": "Der venter måske usendte beskeder her. De går tabt.", - "confirm_templates": "E-mailskabeloner gemt på denne enhed" + "confirm_templates": "E-mailskabeloner gemt på denne enhed", + "session_waiting": "Usendte beskeder, der venter på din næste login: {count}. De bliver til {date}.", + "session_waiting_unknown": "Usendte beskeder venter måske på din næste login. De bliver til {date}.", + "needs_sign_in": "Kræver login", + "switcher_waiting": "Usendte beskeder: {count}. Slettes fra denne enhed den {date}.", + "switcher_kept": "Mailen på denne enhed slettes den {date}.", + "sibling_waiting": "{name} blev logget ud. Usendte beskeder: {count}. Log ind senest {date}, ellers slettes de fra denne enhed.", + "sibling_kept": "{name} blev logget ud. Log ind senest {date}, ellers slettes mailen fra denne enhed." }, "offlineStorage": { "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret på denne enhed. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", diff --git a/locales/de/common.json b/locales/de/common.json index 87c4fa0d7..df5a8374f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Auf Ihr neues Passwort warten ungesendete Nachrichten: {count}. Sie bleiben bis zum {date}.", "refused_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihr neues Passwort. Sie bleiben bis zum {date}.", "confirm_unsent_unknown": "Hier warten möglicherweise ungesendete Nachrichten. Sie gehen verloren.", - "confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen" + "confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen", + "session_waiting": "Ungesendete Nachrichten, die auf Ihre nächste Anmeldung warten: {count}. Sie bleiben bis zum {date}.", + "session_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihre nächste Anmeldung. Sie bleiben bis zum {date}.", + "needs_sign_in": "Anmeldung nötig", + "switcher_waiting": "Ungesendete Nachrichten: {count}. Werden am {date} von diesem Gerät gelöscht.", + "switcher_kept": "Die E-Mails auf diesem Gerät werden am {date} gelöscht.", + "sibling_waiting": "{name} wurde abgemeldet. Ungesendete Nachrichten: {count}. Melden Sie sich bis zum {date} an, sonst werden sie von diesem Gerät gelöscht.", + "sibling_kept": "{name} wurde abgemeldet. Melden Sie sich bis zum {date} an, sonst werden die E-Mails von diesem Gerät gelöscht." }, "offlineStorage": { "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt auf diesem Gerät. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", diff --git a/locales/en/common.json b/locales/en/common.json index fb167d8d3..f0ea6aed0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for your new password, until {date}.", "refused_waiting_unknown": "Unsent messages may be waiting for your new password, until {date}.", "confirm_unsent_unknown": "Unsent messages may be waiting here. They will be lost.", - "confirm_templates": "Email templates saved on this device" + "confirm_templates": "Email templates saved on this device", + "session_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for you to sign in again, until {date}.", + "session_waiting_unknown": "Unsent messages may be waiting for you to sign in again, until {date}.", + "needs_sign_in": "Needs sign-in", + "switcher_waiting": "Unsent messages: {count}. Deleted from this device on {date}.", + "switcher_kept": "Its mail on this device is deleted on {date}.", + "sibling_waiting": "{name} was signed out. Unsent messages: {count}. Sign in by {date} or they're deleted from this device.", + "sibling_kept": "{name} was signed out. Sign in by {date} or its mail is deleted from this device." }, "offlineStorage": { "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted on this device. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", diff --git a/locales/es/common.json b/locales/es/common.json index 21946307b..c850f5716 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Mensajes sin enviar que esperan tu nueva contraseña: {count}. Se conservan hasta el {date}.", "refused_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu nueva contraseña. Se conservan hasta el {date}.", "confirm_unsent_unknown": "Puede que haya mensajes sin enviar esperando aquí. Se perderán.", - "confirm_templates": "Plantillas de correo guardadas en este dispositivo" + "confirm_templates": "Plantillas de correo guardadas en este dispositivo", + "session_waiting": "Mensajes sin enviar que esperan tu próximo inicio de sesión: {count}. Se conservan hasta el {date}.", + "session_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu próximo inicio de sesión. Se conservan hasta el {date}.", + "needs_sign_in": "Requiere iniciar sesión", + "switcher_waiting": "Mensajes sin enviar: {count}. Se eliminarán de este dispositivo el {date}.", + "switcher_kept": "Su correo en este dispositivo se eliminará el {date}.", + "sibling_waiting": "Se ha cerrado la sesión de {name}. Mensajes sin enviar: {count}. Inicia sesión antes del {date} o se eliminarán de este dispositivo.", + "sibling_kept": "Se ha cerrado la sesión de {name}. Inicia sesión antes del {date} o su correo se eliminará de este dispositivo." }, "offlineStorage": { "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este dispositivo. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", diff --git a/locales/et/common.json b/locales/et/common.json index 565e84f5e..eb4f6d999 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Saatmata sõnumid ootavad teie uut parooli: {count}. Need jäävad alles kuni {date}.", "refused_waiting_unknown": "Saatmata sõnumid võivad oodata teie uut parooli. Need jäävad alles kuni {date}.", "confirm_unsent_unknown": "Siin võivad oodata saatmata sõnumid. Need lähevad kaduma.", - "confirm_templates": "Selles seadmes salvestatud e-kirjade mallid" + "confirm_templates": "Selles seadmes salvestatud e-kirjade mallid", + "session_waiting": "Saatmata sõnumid ootavad järgmist sisselogimist: {count}. Need jäävad alles kuni {date}.", + "session_waiting_unknown": "Saatmata sõnumid võivad oodata järgmist sisselogimist. Need jäävad alles kuni {date}.", + "needs_sign_in": "Vajab sisselogimist", + "switcher_waiting": "Saatmata sõnumid: {count}. Kustutatakse sellest seadmest {date}.", + "switcher_kept": "Selle seadme e-kirjad kustutatakse {date}.", + "sibling_waiting": "{name} logiti välja. Saatmata sõnumid: {count}. Logige sisse hiljemalt {date}, muidu kustutatakse need sellest seadmest.", + "sibling_kept": "{name} logiti välja. Logige sisse hiljemalt {date}, muidu kustutatakse e-kirjad sellest seadmest." }, "offlineStorage": { "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles seadmes. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", diff --git a/locales/fa/common.json b/locales/fa/common.json index fb98c6508..44736a55a 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما: {count}. تا {date} نگه داشته می‌شوند.", "refused_waiting_unknown": "شاید پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما باشند. تا {date} نگه داشته می‌شوند.", "confirm_unsent_unknown": "شاید پیام‌های ارسال‌نشده اینجا منتظر باشند. از بین خواهند رفت.", - "confirm_templates": "الگوهای ایمیل ذخیره‌شده در این دستگاه" + "confirm_templates": "الگوهای ایمیل ذخیره‌شده در این دستگاه", + "session_waiting": "پیام‌های ارسال‌نشده در انتظار ورود بعدی شما: {count}. تا {date} نگه داشته می‌شوند.", + "session_waiting_unknown": "شاید پیام‌های ارسال‌نشده در انتظار ورود بعدی شما باشند. تا {date} نگه داشته می‌شوند.", + "needs_sign_in": "نیاز به ورود دارد", + "switcher_waiting": "پیام‌های ارسال‌نشده: {count}. در {date} از این دستگاه حذف می‌شوند.", + "switcher_kept": "ایمیل‌های این دستگاه در {date} حذف می‌شوند.", + "sibling_waiting": "{name} از حساب خارج شد. پیام‌های ارسال‌نشده: {count}. تا {date} وارد شوید، وگرنه از این دستگاه حذف می‌شوند.", + "sibling_kept": "{name} از حساب خارج شد. تا {date} وارد شوید، وگرنه ایمیل‌ها از این دستگاه حذف می‌شوند." }, "offlineStorage": { "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این دستگاه نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", diff --git a/locales/fr/common.json b/locales/fr/common.json index d12e0fd67..543cb40ea 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Messages non envoyés qui attendent votre nouveau mot de passe : {count}. Ils restent jusqu'au {date}.", "refused_waiting_unknown": "Des messages non envoyés attendent peut-être votre nouveau mot de passe. Ils restent jusqu'au {date}.", "confirm_unsent_unknown": "Des messages non envoyés attendent peut-être ici. Ils seront perdus.", - "confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil" + "confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil", + "session_waiting": "Messages non envoyés qui attendent votre prochaine connexion : {count}. Ils restent jusqu'au {date}.", + "session_waiting_unknown": "Des messages non envoyés attendent peut-être votre prochaine connexion. Ils restent jusqu'au {date}.", + "needs_sign_in": "Connexion requise", + "switcher_waiting": "Messages non envoyés : {count}. Supprimés de cet appareil le {date}.", + "switcher_kept": "Ses e-mails sur cet appareil seront supprimés le {date}.", + "sibling_waiting": "{name} a été déconnecté. Messages non envoyés : {count}. Connectez-vous avant le {date}, sinon ils seront supprimés de cet appareil.", + "sibling_kept": "{name} a été déconnecté. Connectez-vous avant le {date}, sinon ses e-mails seront supprimés de cet appareil." }, "offlineStorage": { "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés sur cet appareil. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", diff --git a/locales/he/common.json b/locales/he/common.json index 1739d0d64..3422aa00b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5784,7 +5784,14 @@ "refused_waiting": "הודעות שלא נשלחו ממתינות לסיסמה החדשה שלך: {count}. הן יישמרו עד {date}.", "refused_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לסיסמה החדשה שלך. הן יישמרו עד {date}.", "confirm_unsent_unknown": "ייתכן שיש כאן הודעות שלא נשלחו. הן יאבדו.", - "confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה" + "confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה", + "session_waiting": "הודעות שלא נשלחו ממתינות לכניסה הבאה שלך: {count}. הן יישמרו עד {date}.", + "session_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לכניסה הבאה שלך. הן יישמרו עד {date}.", + "needs_sign_in": "נדרשת כניסה", + "switcher_waiting": "הודעות שלא נשלחו: {count}. יימחקו מהמכשיר הזה ב-{date}.", + "switcher_kept": "הדואר במכשיר הזה יימחק ב-{date}.", + "sibling_waiting": "{name} נותק. הודעות שלא נשלחו: {count}. יש להיכנס עד {date}, אחרת הן יימחקו מהמכשיר הזה.", + "sibling_kept": "{name} נותק. יש להיכנס עד {date}, אחרת הדואר יימחק מהמכשיר הזה." }, "offlineStorage": { "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים במכשיר הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 9effe4bc5..8f3a503ee 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Az új jelszóra váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.", "refused_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak az új jelszóra. Eddig maradnak: {date}.", "confirm_unsent_unknown": "Lehet, hogy el nem küldött üzenetek várakoznak itt. Elvesznek.", - "confirm_templates": "Ezen az eszközön mentett e-mail-sablonok" + "confirm_templates": "Ezen az eszközön mentett e-mail-sablonok", + "session_waiting": "A következő bejelentkezésre váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.", + "session_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak a következő bejelentkezésre. Eddig maradnak: {date}.", + "needs_sign_in": "Bejelentkezés szükséges", + "switcher_waiting": "El nem küldött üzenetek: {count}. Törlés erről az eszközről: {date}.", + "switcher_kept": "Az eszközön lévő levelek törlése: {date}.", + "sibling_waiting": "{name} kijelentkezett. El nem küldött üzenetek: {count}. Jelentkezzen be eddig: {date}, különben törlődnek erről az eszközről.", + "sibling_kept": "{name} kijelentkezett. Jelentkezzen be eddig: {date}, különben a levelek törlődnek erről az eszközről." }, "offlineStorage": { "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ezen az eszközön. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", diff --git a/locales/it/common.json b/locales/it/common.json index 8bae2befc..662049227 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Messaggi non inviati in attesa della nuova password: {count}. Restano fino al {date}.", "refused_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa della nuova password. Restano fino al {date}.", "confirm_unsent_unknown": "Qui potrebbero esserci messaggi non inviati in attesa. Andranno persi.", - "confirm_templates": "Modelli di email salvati su questo dispositivo" + "confirm_templates": "Modelli di email salvati su questo dispositivo", + "session_waiting": "Messaggi non inviati in attesa del prossimo accesso: {count}. Restano fino al {date}.", + "session_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa del prossimo accesso. Restano fino al {date}.", + "needs_sign_in": "Accesso richiesto", + "switcher_waiting": "Messaggi non inviati: {count}. Eliminati da questo dispositivo il {date}.", + "switcher_kept": "Le sue email su questo dispositivo verranno eliminate il {date}.", + "sibling_waiting": "{name} è stato disconnesso. Messaggi non inviati: {count}. Accedi entro il {date} o verranno eliminati da questo dispositivo.", + "sibling_kept": "{name} è stato disconnesso. Accedi entro il {date} o le sue email verranno eliminate da questo dispositivo." }, "offlineStorage": { "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati su questo dispositivo. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 5d337d274..53cd6fbf5 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "新しいパスワードを待っている未送信メッセージ: {count} 件。{date} まで保持されます。", "refused_waiting_unknown": "未送信のメッセージが新しいパスワードを待っている可能性があります。{date} まで保持されます。", "confirm_unsent_unknown": "ここに未送信のメッセージがある可能性があります。失われます。", - "confirm_templates": "このデバイスに保存されたメールテンプレート" + "confirm_templates": "このデバイスに保存されたメールテンプレート", + "session_waiting": "次回のサインインを待っている未送信メッセージ: {count} 件。{date} まで保持されます。", + "session_waiting_unknown": "未送信のメッセージが次回のサインインを待っている可能性があります。{date} まで保持されます。", + "needs_sign_in": "サインインが必要", + "switcher_waiting": "未送信メッセージ: {count} 件。{date} にこのデバイスから削除されます。", + "switcher_kept": "このデバイス上のメールは {date} に削除されます。", + "sibling_waiting": "{name} はサインアウトされました。未送信メッセージ: {count} 件。{date} までにサインインしないと、このデバイスから削除されます。", + "sibling_kept": "{name} はサインアウトされました。{date} までにサインインしないと、メールはこのデバイスから削除されます。" }, "offlineStorage": { "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのデバイスに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index a0cc9e7a9..de35b0ed0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "새 비밀번호를 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.", "refused_waiting_unknown": "보내지 않은 메시지가 새 비밀번호를 기다리고 있을 수 있습니다. {date}까지 보관됩니다.", "confirm_unsent_unknown": "여기에 보내지 않은 메시지가 있을 수 있습니다. 사라지게 됩니다.", - "confirm_templates": "이 기기에 저장된 이메일 템플릿" + "confirm_templates": "이 기기에 저장된 이메일 템플릿", + "session_waiting": "다음 로그인을 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.", + "session_waiting_unknown": "보내지 않은 메시지가 다음 로그인을 기다리고 있을 수 있습니다. {date}까지 보관됩니다.", + "needs_sign_in": "로그인 필요", + "switcher_waiting": "보내지 않은 메시지: {count}개. {date}에 이 기기에서 삭제됩니다.", + "switcher_kept": "이 기기의 메일은 {date}에 삭제됩니다.", + "sibling_waiting": "{name} 계정이 로그아웃되었습니다. 보내지 않은 메시지: {count}개. {date}까지 로그인하지 않으면 이 기기에서 삭제됩니다.", + "sibling_kept": "{name} 계정이 로그아웃되었습니다. {date}까지 로그인하지 않으면 메일이 이 기기에서 삭제됩니다." }, "offlineStorage": { "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 기기에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 3d0c0a8b7..b6796c731 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Nenosūtītie ziņojumi gaida jūsu jauno paroli: {count}. Tie paliek līdz {date}.", "refused_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida jūsu jauno paroli. Tie paliek līdz {date}.", "confirm_unsent_unknown": "Šeit var būt nenosūtīti ziņojumi. Tie tiks zaudēti.", - "confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes" + "confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes", + "session_waiting": "Nenosūtītie ziņojumi gaida nākamo pieteikšanos: {count}. Tie paliek līdz {date}.", + "session_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida nākamo pieteikšanos. Tie paliek līdz {date}.", + "needs_sign_in": "Nepieciešama pieteikšanās", + "switcher_waiting": "Nenosūtītie ziņojumi: {count}. Tiks izdzēsti no šīs ierīces {date}.", + "switcher_kept": "E-pasts šajā ierīcē tiks izdzēsts {date}.", + "sibling_waiting": "{name} tika izrakstīts. Nenosūtītie ziņojumi: {count}. Piesakieties līdz {date}, citādi tie tiks izdzēsti no šīs ierīces.", + "sibling_kept": "{name} tika izrakstīts. Piesakieties līdz {date}, citādi e-pasts tiks izdzēsts no šīs ierīces." }, "offlineStorage": { "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā ierīcē. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 4ede4b17f..bbd32ac3c 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Шинэ нууц үгийг тань хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.", "refused_waiting_unknown": "Илгээгээгүй зурвасууд шинэ нууц үгийг тань хүлээж байж магадгүй. {date} хүртэл хадгалагдана.", "confirm_unsent_unknown": "Энд илгээгээгүй зурвас хүлээж байж магадгүй. Тэдгээр нь алдагдана.", - "confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд" + "confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд", + "session_waiting": "Дараагийн нэвтрэлтийг хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.", + "session_waiting_unknown": "Илгээгээгүй зурвасууд дараагийн нэвтрэлтийг хүлээж байж магадгүй. {date} хүртэл хадгалагдана.", + "needs_sign_in": "Нэвтрэх шаардлагатай", + "switcher_waiting": "Илгээгээгүй зурвас: {count}. {date}-нд энэ төхөөрөмжөөс устгагдана.", + "switcher_kept": "Энэ төхөөрөмж дээрх имэйл {date}-нд устгагдана.", + "sibling_waiting": "{name} системээс гарсан. Илгээгээгүй зурвас: {count}. {date} хүртэл нэвтэрнэ үү, эс бөгөөс энэ төхөөрөмжөөс устгагдана.", + "sibling_kept": "{name} системээс гарсан. {date} хүртэл нэвтэрнэ үү, эс бөгөөс имэйл энэ төхөөрөмжөөс устгагдана." }, "offlineStorage": { "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ төхөөрөмж дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", diff --git a/locales/nb/common.json b/locales/nb/common.json index b507ee77d..05c122103 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Usendte meldinger som venter på det nye passordet: {count}. De blir til {date}.", "refused_waiting_unknown": "Usendte meldinger venter kanskje på det nye passordet. De blir til {date}.", "confirm_unsent_unknown": "Det venter kanskje usendte meldinger her. De går tapt.", - "confirm_templates": "E-postmaler lagret på denne enheten" + "confirm_templates": "E-postmaler lagret på denne enheten", + "session_waiting": "Usendte meldinger som venter på neste pålogging: {count}. De blir til {date}.", + "session_waiting_unknown": "Usendte meldinger venter kanskje på neste pålogging. De blir til {date}.", + "needs_sign_in": "Krever pålogging", + "switcher_waiting": "Usendte meldinger: {count}. Slettes fra denne enheten {date}.", + "switcher_kept": "E-posten på denne enheten slettes {date}.", + "sibling_waiting": "{name} ble logget ut. Usendte meldinger: {count}. Logg på innen {date}, ellers slettes de fra denne enheten.", + "sibling_kept": "{name} ble logget ut. Logg på innen {date}, ellers slettes e-posten fra denne enheten." }, "offlineStorage": { "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert på denne enheten. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 0cec4efb0..0f8ff3e20 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Niet-verzonden berichten die op uw nieuwe wachtwoord wachten: {count}. Ze blijven tot {date}.", "refused_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw nieuwe wachtwoord. Ze blijven tot {date}.", "confirm_unsent_unknown": "Hier wachten mogelijk niet-verzonden berichten. Ze gaan verloren.", - "confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen" + "confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen", + "session_waiting": "Niet-verzonden berichten die op uw volgende aanmelding wachten: {count}. Ze blijven tot {date}.", + "session_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw volgende aanmelding. Ze blijven tot {date}.", + "needs_sign_in": "Aanmelden nodig", + "switcher_waiting": "Niet-verzonden berichten: {count}. Op {date} van dit apparaat verwijderd.", + "switcher_kept": "De e-mail op dit apparaat wordt op {date} verwijderd.", + "sibling_waiting": "{name} is afgemeld. Niet-verzonden berichten: {count}. Meld u uiterlijk {date} aan, anders worden ze van dit apparaat verwijderd.", + "sibling_kept": "{name} is afgemeld. Meld u uiterlijk {date} aan, anders wordt de e-mail van dit apparaat verwijderd." }, "offlineStorage": { "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld op dit apparaat. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", diff --git a/locales/pl/common.json b/locales/pl/common.json index ad520117b..2b1acf10f 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Niewysłane wiadomości czekające na nowe hasło: {count}. Pozostaną do {date}.", "refused_waiting_unknown": "Niewysłane wiadomości mogą czekać na nowe hasło. Pozostaną do {date}.", "confirm_unsent_unknown": "Tutaj mogą czekać niewysłane wiadomości. Zostaną utracone.", - "confirm_templates": "Szablony e-maili zapisane na tym urządzeniu" + "confirm_templates": "Szablony e-maili zapisane na tym urządzeniu", + "session_waiting": "Niewysłane wiadomości czekające na następne logowanie: {count}. Pozostaną do {date}.", + "session_waiting_unknown": "Niewysłane wiadomości mogą czekać na następne logowanie. Pozostaną do {date}.", + "needs_sign_in": "Wymaga zalogowania", + "switcher_waiting": "Niewysłane wiadomości: {count}. Zostaną usunięte z tego urządzenia {date}.", + "switcher_kept": "Poczta na tym urządzeniu zostanie usunięta {date}.", + "sibling_waiting": "Konto {name} zostało wylogowane. Niewysłane wiadomości: {count}. Zaloguj się do {date}, inaczej zostaną usunięte z tego urządzenia.", + "sibling_kept": "Konto {name} zostało wylogowane. Zaloguj się do {date}, inaczej poczta zostanie usunięta z tego urządzenia." }, "offlineStorage": { "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane na tym urządzeniu. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", diff --git a/locales/pt/common.json b/locales/pt/common.json index d7ccc37d4..3e1c1dbe3 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Mensagens por enviar à espera da nova palavra-passe: {count}. Ficam até {date}.", "refused_waiting_unknown": "Podem existir mensagens por enviar à espera da nova palavra-passe. Ficam até {date}.", "confirm_unsent_unknown": "Podem existir mensagens por enviar aqui. Serão perdidas.", - "confirm_templates": "Modelos de e-mail guardados neste dispositivo" + "confirm_templates": "Modelos de e-mail guardados neste dispositivo", + "session_waiting": "Mensagens por enviar à espera do próximo início de sessão: {count}. Ficam até {date}.", + "session_waiting_unknown": "Podem existir mensagens por enviar à espera do próximo início de sessão. Ficam até {date}.", + "needs_sign_in": "Requer início de sessão", + "switcher_waiting": "Mensagens por enviar: {count}. Eliminadas deste dispositivo em {date}.", + "switcher_kept": "O e-mail neste dispositivo será eliminado em {date}.", + "sibling_waiting": "A sessão de {name} foi terminada. Mensagens por enviar: {count}. Inicie sessão até {date} ou serão eliminadas deste dispositivo.", + "sibling_kept": "A sessão de {name} foi terminada. Inicie sessão até {date} ou o e-mail será eliminado deste dispositivo." }, "offlineStorage": { "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste dispositivo. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", diff --git a/locales/ro/common.json b/locales/ro/common.json index a5693b91e..568831fed 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Mesaje netrimise care așteaptă parola nouă: {count}. Rămân până la {date}.", "refused_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte parola nouă. Rămân până la {date}.", "confirm_unsent_unknown": "Este posibil să existe aici mesaje netrimise. Se vor pierde.", - "confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv" + "confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv", + "session_waiting": "Mesaje netrimise care așteaptă următoarea conectare: {count}. Rămân până la {date}.", + "session_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte următoarea conectare. Rămân până la {date}.", + "needs_sign_in": "Necesită conectare", + "switcher_waiting": "Mesaje netrimise: {count}. Vor fi șterse de pe acest dispozitiv la {date}.", + "switcher_kept": "E-mailurile de pe acest dispozitiv vor fi șterse la {date}.", + "sibling_waiting": "{name} a fost deconectat. Mesaje netrimise: {count}. Conectați-vă până la {date}, altfel vor fi șterse de pe acest dispozitiv.", + "sibling_kept": "{name} a fost deconectat. Conectați-vă până la {date}, altfel e-mailurile vor fi șterse de pe acest dispozitiv." }, "offlineStorage": { "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate pe acest dispozitiv. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 741db287f..5b2b66dc8 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Неотправленные сообщения ждут нового пароля: {count}. Они хранятся до {date}.", "refused_waiting_unknown": "Возможно, неотправленные сообщения ждут нового пароля. Они хранятся до {date}.", "confirm_unsent_unknown": "Здесь могут быть неотправленные сообщения. Они будут потеряны.", - "confirm_templates": "Шаблоны писем, сохранённые на этом устройстве" + "confirm_templates": "Шаблоны писем, сохранённые на этом устройстве", + "session_waiting": "Неотправленные сообщения ждут следующего входа: {count}. Они хранятся до {date}.", + "session_waiting_unknown": "Возможно, неотправленные сообщения ждут следующего входа. Они хранятся до {date}.", + "needs_sign_in": "Нужен вход", + "switcher_waiting": "Неотправленные сообщения: {count}. Будут удалены с этого устройства {date}.", + "switcher_kept": "Почта на этом устройстве будет удалена {date}.", + "sibling_waiting": "Выполнен выход из {name}. Неотправленные сообщения: {count}. Войдите до {date}, иначе они будут удалены с этого устройства.", + "sibling_kept": "Выполнен выход из {name}. Войдите до {date}, иначе почта будет удалена с этого устройства." }, "offlineStorage": { "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде на этом устройстве. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 51853a98d..60bd9fd93 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Neodoslané správy čakajúce na nové heslo: {count}. Zostanú do {date}.", "refused_waiting_unknown": "Na nové heslo možno čakajú neodoslané správy. Zostanú do {date}.", "confirm_unsent_unknown": "Možno tu čakajú neodoslané správy. Stratia sa.", - "confirm_templates": "Šablóny e-mailov uložené v tomto zariadení" + "confirm_templates": "Šablóny e-mailov uložené v tomto zariadení", + "session_waiting": "Neodoslané správy čakajúce na ďalšie prihlásenie: {count}. Zostanú do {date}.", + "session_waiting_unknown": "Na ďalšie prihlásenie možno čakajú neodoslané správy. Zostanú do {date}.", + "needs_sign_in": "Vyžaduje prihlásenie", + "switcher_waiting": "Neodoslané správy: {count}. Odstránia sa z tohto zariadenia {date}.", + "switcher_kept": "Pošta v tomto zariadení sa odstráni {date}.", + "sibling_waiting": "Účet {name} bol odhlásený. Neodoslané správy: {count}. Prihláste sa do {date}, inak sa z tohto zariadenia odstránia.", + "sibling_kept": "Účet {name} bol odhlásený. Prihláste sa do {date}, inak sa pošta z tohto zariadenia odstráni." }, "offlineStorage": { "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto zariadení. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", diff --git a/locales/th/common.json b/locales/th/common.json index 3daeb90b6..15c58391e 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2128,7 +2128,14 @@ "refused_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอรหัสผ่านใหม่ของคุณ: {count} เก็บไว้ถึง {date}", "refused_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอรหัสผ่านใหม่ของคุณ เก็บไว้ถึง {date}", "confirm_unsent_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรออยู่ที่นี่ ข้อความเหล่านั้นจะสูญหาย", - "confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้" + "confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้", + "session_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอการลงชื่อเข้าใช้ครั้งถัดไป: {count} เก็บไว้ถึง {date}", + "session_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอการลงชื่อเข้าใช้ครั้งถัดไป เก็บไว้ถึง {date}", + "needs_sign_in": "ต้องลงชื่อเข้าใช้", + "switcher_waiting": "ข้อความที่ยังไม่ได้ส่ง: {count} จะถูกลบออกจากอุปกรณ์นี้ในวันที่ {date}", + "switcher_kept": "อีเมลในอุปกรณ์นี้จะถูกลบในวันที่ {date}", + "sibling_waiting": "{name} ถูกออกจากระบบแล้ว ข้อความที่ยังไม่ได้ส่ง: {count} โปรดลงชื่อเข้าใช้ภายใน {date} มิฉะนั้นจะถูกลบออกจากอุปกรณ์นี้", + "sibling_kept": "{name} ถูกออกจากระบบแล้ว โปรดลงชื่อเข้าใช้ภายใน {date} มิฉะนั้นอีเมลจะถูกลบออกจากอุปกรณ์นี้" }, "offlineStorage": { "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในอุปกรณ์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4b9b55b32..2b7142fec 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Yeni parolanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.", "refused_waiting_unknown": "Gönderilmemiş iletiler yeni parolanızı bekliyor olabilir. {date} tarihine kadar kalır.", "confirm_unsent_unknown": "Burada gönderilmemiş iletiler bekliyor olabilir. Kaybolacaklar.", - "confirm_templates": "Bu cihazda kayıtlı e-posta şablonları" + "confirm_templates": "Bu cihazda kayıtlı e-posta şablonları", + "session_waiting": "Bir sonraki oturum açmanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.", + "session_waiting_unknown": "Gönderilmemiş iletiler bir sonraki oturum açmanızı bekliyor olabilir. {date} tarihine kadar kalır.", + "needs_sign_in": "Oturum açılması gerekiyor", + "switcher_waiting": "Gönderilmemiş iletiler: {count}. {date} tarihinde bu cihazdan silinecek.", + "switcher_kept": "Bu cihazdaki e-postalar {date} tarihinde silinecek.", + "sibling_waiting": "{name} oturumu kapatıldı. Gönderilmemiş iletiler: {count}. {date} tarihine kadar oturum açın, yoksa bu cihazdan silinecekler.", + "sibling_kept": "{name} oturumu kapatıldı. {date} tarihine kadar oturum açın, yoksa e-postalar bu cihazdan silinecek." }, "offlineStorage": { "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu cihazda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 8b5819736..f226aa677 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Ненадіслані повідомлення чекають на новий пароль: {count}. Вони зберігаються до {date}.", "refused_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на новий пароль. Вони зберігаються до {date}.", "confirm_unsent_unknown": "Тут можуть бути ненадіслані повідомлення. Їх буде втрачено.", - "confirm_templates": "Шаблони листів, збережені на цьому пристрої" + "confirm_templates": "Шаблони листів, збережені на цьому пристрої", + "session_waiting": "Ненадіслані повідомлення чекають на наступний вхід: {count}. Вони зберігаються до {date}.", + "session_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на наступний вхід. Вони зберігаються до {date}.", + "needs_sign_in": "Потрібен вхід", + "switcher_waiting": "Ненадіслані повідомлення: {count}. Буде видалено з цього пристрою {date}.", + "switcher_kept": "Пошту на цьому пристрої буде видалено {date}.", + "sibling_waiting": "Виконано вихід із {name}. Ненадіслані повідомлення: {count}. Увійдіть до {date}, інакше їх буде видалено з цього пристрою.", + "sibling_kept": "Виконано вихід із {name}. Увійдіть до {date}, інакше пошту буде видалено з цього пристрою." }, "offlineStorage": { "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді на цьому пристрої. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 8ba0c8d1e..46c8b3dec 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5795,7 +5795,14 @@ "refused_waiting": "Thư chưa gửi đang chờ mật khẩu mới của bạn: {count}. Được giữ đến {date}.", "refused_waiting_unknown": "Có thể có thư chưa gửi đang chờ mật khẩu mới của bạn. Được giữ đến {date}.", "confirm_unsent_unknown": "Có thể có thư chưa gửi đang chờ ở đây. Chúng sẽ bị mất.", - "confirm_templates": "Mẫu email đã lưu trên thiết bị này" + "confirm_templates": "Mẫu email đã lưu trên thiết bị này", + "session_waiting": "Thư chưa gửi đang chờ lần đăng nhập tiếp theo: {count}. Được giữ đến {date}.", + "session_waiting_unknown": "Có thể có thư chưa gửi đang chờ lần đăng nhập tiếp theo. Được giữ đến {date}.", + "needs_sign_in": "Cần đăng nhập", + "switcher_waiting": "Thư chưa gửi: {count}. Sẽ bị xóa khỏi thiết bị này vào {date}.", + "switcher_kept": "Email trên thiết bị này sẽ bị xóa vào {date}.", + "sibling_waiting": "{name} đã bị đăng xuất. Thư chưa gửi: {count}. Hãy đăng nhập trước {date}, nếu không chúng sẽ bị xóa khỏi thiết bị này.", + "sibling_kept": "{name} đã bị đăng xuất. Hãy đăng nhập trước {date}, nếu không email sẽ bị xóa khỏi thiết bị này." }, "offlineStorage": { "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trên thiết bị này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 01ce18e04..1f5630127 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "等待新密碼的未傳送郵件:{count} 封。保留至 {date}。", "refused_waiting_unknown": "可能有未傳送的郵件正在等待新密碼。保留至 {date}。", "confirm_unsent_unknown": "這裡可能有未傳送的郵件。它們將會遺失。", - "confirm_templates": "儲存在此裝置上的郵件範本" + "confirm_templates": "儲存在此裝置上的郵件範本", + "session_waiting": "等待下次登入的未傳送郵件:{count} 封。保留至 {date}。", + "session_waiting_unknown": "可能有未傳送的郵件正在等待下次登入。保留至 {date}。", + "needs_sign_in": "需要登入", + "switcher_waiting": "未傳送郵件:{count} 封。將於 {date} 從此裝置刪除。", + "switcher_kept": "此裝置上的郵件將於 {date} 刪除。", + "sibling_waiting": "{name} 已登出。未傳送郵件:{count} 封。請在 {date} 前登入,否則將從此裝置刪除。", + "sibling_kept": "{name} 已登出。請在 {date} 前登入,否則郵件將從此裝置刪除。" }, "offlineStorage": { "keep_description": "在此裝置上加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 54d3eac4b..0643bda66 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "等待新密码的未发送邮件:{count} 封。保留至 {date}。", "refused_waiting_unknown": "可能有未发送的邮件在等待新密码。保留至 {date}。", "confirm_unsent_unknown": "这里可能有未发送的邮件。它们将会丢失。", - "confirm_templates": "保存在此设备上的邮件模板" + "confirm_templates": "保存在此设备上的邮件模板", + "session_waiting": "等待下次登录的未发送邮件:{count} 封。保留至 {date}。", + "session_waiting_unknown": "可能有未发送的邮件在等待下次登录。保留至 {date}。", + "needs_sign_in": "需要登录", + "switcher_waiting": "未发送邮件:{count} 封。将于 {date} 从此设备删除。", + "switcher_kept": "此设备上的邮件将于 {date} 删除。", + "sibling_waiting": "{name} 已退出登录。未发送邮件:{count} 封。请在 {date} 前登录,否则将从此设备删除。", + "sibling_kept": "{name} 已退出登录。请在 {date} 前登录,否则邮件将从此设备删除。" }, "offlineStorage": { "keep_description": "在此设备上加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", diff --git a/stores/__tests__/auth-store-device-wipe.test.ts b/stores/__tests__/auth-store-device-wipe.test.ts index 1f1d9a00b..52c036b00 100644 --- a/stores/__tests__/auth-store-device-wipe.test.ts +++ b/stores/__tests__/auth-store-device-wipe.test.ts @@ -51,8 +51,9 @@ vi.mock('@/lib/offline/offline-mode', () => ({ const heard = vi.hoisted(() => [] as string[]); const certain = vi.hoisted(() => [] as boolean[]); +const kinds = vi.hoisted(() => [] as string[]); vi.mock('@/lib/offline/credentials-rejected', () => ({ - noteCredentialsRejected: (account: string, sure = true) => { heard.push(account); certain.push(sure); }, + noteCredentialsRejected: (account: string, sure = true, kind = 'refused') => { heard.push(account); certain.push(sure); kinds.push(kind); }, onCredentialsRejected: () => () => {}, })); @@ -97,6 +98,7 @@ beforeEach(async () => { order.length = 0; heard.length = 0; certain.length = 0; + kinds.length = 0; clients.made.length = 0; clients.connect = async () => {}; gate.run = async () => {}; @@ -189,14 +191,15 @@ describe('a login the server ended, at a start', () => { it.each([ ['a password login', 'basic', '/api/auth/session'], ['an OAuth login', 'oauth', '/api/auth/token'], - ])('the session limit is no refusal, for %s: no hook, no wait, and the account is only dropped', async (_name, mode, route) => { + ])('the session limit goes the same way, as what it is, for %s: nothing about the password', async (_name, mode, route) => { useOfflineStore.setState({ enabled: [ALICE] }); useAccountStore.setState({ accounts: [account(ALICE, 0, mode as 'basic' | 'oauth')] } as never); answer = (url, method) => (url.includes(route) && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); await useAuthStore.getState().checkAuth(); await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); - expect(heard).toEqual([]); - expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([ALICE]); + expect(kinds).toEqual(['session']); + expect(readRefusedLogins()[ALICE]).toMatchObject({ kind: 'session', certain: true }); expect(isEvicted(ALICE)).toBe(true); expect(useOfflineStore.getState().enabled).toEqual([ALICE]); }); @@ -239,11 +242,12 @@ describe('a login the server ended, at a switch', () => { expect(useOfflineStore.getState().enabled).toContain(BOB); }); - it('the session limit doesn\'t fire it and only drops the account', async () => { + it('the session limit fires it as what it is, and only drops the account', async () => { useOfflineStore.setState({ enabled: [ALICE, BOB] }); answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); await useAuthStore.getState().switchAccount(BOB); - expect(heard).toEqual([]); + expect(heard).toEqual([BOB]); + expect(kinds).toEqual(['session']); expect(isEvicted(BOB)).toBe(true); expect(useOfflineStore.getState().enabled).toContain(BOB); }); @@ -255,6 +259,33 @@ describe('a login the server ended, at a switch', () => { }); }); +describe('a refusal with another account signed in', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth'), account(BOB, 1, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE, isAuthenticated: true }); + }); + + it('writes down who was there, so the note reaches them and nobody later', async () => { + useOfflineStore.setState({ enabled: [ALICE, BOB] }); + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); + await useAuthStore.getState().refreshAccessToken({ accountId: BOB }); + expect(readRefusedLogins()[BOB]).toMatchObject({ seenBy: [ALICE], label: BOB, username: 'bob' }); + }); +}); + +describe('a login with no "Remember me", when the browser closed', () => { + it('still takes its data with it: that was the choice on a shared computer', async () => { + const reasons = await removalReasons(); + useAccountStore.setState({ accounts: [{ ...account(ALICE, 0, 'basic'), rememberMe: false }], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + useOfflineStore.setState({ enabled: [ALICE] }); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(reasons).toEqual(['explicit'])); + expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([]); + }); +}); + describe('a password the server keeps refusing', () => { beforeEach(() => { useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 1b26004ee..8854e5197 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -39,7 +39,7 @@ import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; import { withTokenLock } from '@/lib/auth/token-lock'; import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel'; import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role'; -import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; +import { noteCredentialsRejected, type ForcedSignOutKind } from '@/lib/offline/credentials-rejected'; import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state'; import { markEvicted, readEvicted, useOfflineStore } from '@/lib/offline/state'; import { toAsciiEmail } from '@/lib/idn'; @@ -176,6 +176,8 @@ export interface LogoutOptions { * hidden, for the next sign-in or 30 days. */ keepLocalData?: boolean; + /** It's the organisation's idle or session limit that ended it: with `keepLocalData`, what lib/offline/device-wipe.ts is told. */ + sessionLimit?: boolean; } const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [ @@ -805,18 +807,27 @@ function holdClient(accountId: string, client: JMAPClient): void { } /** - * The server refused this login, and the data hides from here on. It waits for the - * new password, or goes after 30 days, or now if the organisation asks for that + * This login ended with nobody asking: the server refused it, or the organisation's + * idle or session limit ran out. The data hides from here on. It waits for the next + * sign-in, or goes after 30 days, or now if the organisation asks for that * (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). The usual cause is a * password changed on another device: nothing goes by itself, unsent mail included. - * `certain`: `invalid_grant`, or a 401 that lasted. Not one bad answer at a connect. + * `certain`: not one bad answer at a connect. */ -function noteRefusedLogin(accountId: string, certain: boolean): void { +function noteRefusedLogin(accountId: string, certain: boolean, kind: ForcedSignOutKind = 'refused'): void { if (useOfflineStore.getState().enabled.includes(accountId)) { - markLoginRefused(accountId, certain); + const { accounts } = useAccountStore.getState(); + const account = accounts.find((entry) => entry.id === accountId); + markLoginRefused(accountId, { + certain, + kind, + seenBy: accounts.filter((entry) => entry.id !== accountId).map((entry) => entry.id), + label: account?.email || account?.username, + username: account?.username, + }); markEvicted(accountId); } - noteCredentialsRejected(accountId, certain); + noteCredentialsRejected(accountId, certain, kind); } /** @@ -1315,17 +1326,19 @@ function recordPermanentRefreshFailure(status: number, accountId?: string): bool } /** - * Whether the server refused the login itself: invalid_grant. That signs out and - * hides the data (noteRefusedLogin). Anything else keeps it the same way with no - * word to anyone, including a body we can't read and the organisation's session - * limit, which says the session is old and nothing about the password. + * What a refused renewal says about the login: the server refused it + * (invalid_grant), or the organisation's session limit was reached, which says + * nothing about the password. Both sign out and hide the data (noteRefusedLogin). + * Anything else keeps it the same way with no word to anyone, including a body + * we can't read. */ -async function loginRefused(res: Response): Promise { +async function forcedSignOutOf(res: Response): Promise { try { const { error } = await res.clone().json() as { error?: unknown }; - return error === 'invalid_grant'; + if (error === 'invalid_grant') return 'refused'; + return error === SESSION_TOO_OLD ? 'session' : null; } catch { - return false; + return null; } } @@ -2398,15 +2411,15 @@ export const useAuthStore = create()( // maintenance windows and offline spells. if (res.status === 401) { resetRefreshBackoff(accountId ?? undefined); - const refused = await loginRefused(res); + const forced = await forcedSignOutOf(res); // Only this login's refresh token was refused. if (accountId && !isActive) { - if (refused) noteRefusedLogin(accountId, true); + if (forced) noteRefusedLogin(accountId, true, forced); get().removeAccount(accountId, 'evicted'); return null; } await saveDraftsBeforeExpiry(slot); - if (refused && accountId) noteRefusedLogin(accountId, true); + if (forced && accountId) noteRefusedLogin(accountId, true, forced); notifyParent('sso:session-expired'); markSessionExpired(); get().logout({ endProviderSession: false, keepLocalData: true }); @@ -2669,7 +2682,12 @@ export const useAuthStore = create()( logoutAll: async (options) => { if (options?.endProviderSession !== false && !(await passSignOutGates('all'))) return; - await beforeLogoutHooks('all'); + const keepLocalData = options?.keepLocalData === true; + // Before anything goes, while the accounts are still there to be named. + if (options?.sessionLimit) { + for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session'); + } + await beforeLogoutHooks('all', { keepLocalStore: keepLocalData }); const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts]; @@ -2696,11 +2714,11 @@ export const useAuthStore = create()( // Credentials are cleared before the page state - see logout(). const endSessionUrl = await cleanup; - performFullLogout(set); + performFullLogout(set, { keepLocalStore: keepLocalData }); // Clear all accounts from registry for (const account of allAccounts) { - accountStore.removeAccount(account.id); + accountStore.removeAccount(account.id, keepLocalData ? 'evicted' : 'explicit'); } clearKeystoreGrants(); @@ -2739,7 +2757,7 @@ export const useAuthStore = create()( let targetClient = clients.get(accountId); const wasConnected = !!targetClient; let targetRestoreRateLimited = false; - let targetRefused = false; + let targetForced: ForcedSignOutKind | null = null; if (!targetClient) { // Null out the client immediately so the page doesn't fire data-loading @@ -2756,7 +2774,7 @@ export const useAuthStore = create()( try { if (targetAccount.authMode === 'oauth') { const res = await fetchSlotAccessToken(targetAccount.cookieSlot, { remember: targetAccount.rememberMe }); - if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); + if (!res.ok && res.status === 401) targetForced = await forcedSignOutOf(res); if (res.ok) { const { access_token, expires_in } = await res.json(); targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => get().refreshAccessToken({ accountId })); @@ -2773,7 +2791,7 @@ export const useAuthStore = create()( } } else if (targetAccount.rememberMe) { const res = await fetchSlotSession(targetAccount.cookieSlot); - if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); + if (!res.ok && res.status === 401) targetForced = await forcedSignOutOf(res); if (res.ok) { const session: RememberedSession = await res.json(); targetClient = clientForSession(session); @@ -2825,7 +2843,8 @@ export const useAuthStore = create()( } // Cannot restore - remove the stale account and redirect to login - if (targetRefused || refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, targetRefused); + if (targetForced) noteRefusedLogin(accountId, true, targetForced); + else if (refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, false); refusedAtConnect.delete(accountId); evictAccount(accountId); accountStore.removeAccount(accountId, 'evicted'); @@ -3086,7 +3105,7 @@ export const useAuthStore = create()( // Repeated 500/502 (see recordPermanentRefreshFailure) falls // through here and evicts the account like a rejection. (#972) if (res.status >= 500) notifySignInAgain(); - throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { refused: await loginRefused(res) }); + throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { forced: await forcedSignOutOf(res) }); } } else { const res = await fetchSlotSession(account.cookieSlot); @@ -3107,7 +3126,7 @@ export const useAuthStore = create()( } else if (res.status >= 500 || res.status === 429) { throw new TransientAuthError('Session restore failed', res.status); } else { - throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { refused: await loginRefused(res) }); + throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { forced: await forcedSignOutOf(res) }); } } } catch (err) { @@ -3145,8 +3164,9 @@ export const useAuthStore = create()( // again rather than seeing a stale error entry forever. Its // refresh token goes with it: nothing would use or revoke it. Its // local data waits. - const refused = (err as { refused?: boolean }).refused === true; - if (refused || refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, refused); + const { forced } = err as { forced?: ForcedSignOutKind | null }; + if (forced) noteRefusedLogin(account.id, true, forced); + else if (refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, false); refusedAtConnect.delete(account.id); evictAccount(account.id); accountStore.removeAccount(account.id, 'evicted'); From 383e579eec5076d9cc4aa33eb54211520b2f82fd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:52:42 +0100 Subject: [PATCH 38/45] feat: run the app to the top of the window, with the window's controls in its own toolbars The desktop app and an installed PWA had a row across the whole window with the title and the window controls. That row is gone. The rail, sidebar, list and reader run to the top of the window in their own colours, and the controls sit in the top row of whatever is rightmost. - `lib/window-controls-overlay.ts` finds and marks those rows before paint, and they reserve the controls' width through `--titlebar-right-inset` - the rows and the rail are the drag area, everything clickable is `no-drag`, and an invisible band lets a page with no row still move - settings and the admin shell get a row only under the overlay, Pro's tab strip and Mountain View's bar match the controls' height - Alt floats the menu over the top left, in `window-menu-bar.tsx` - skeletons carry the same marks, so nothing shifts when the app loads --- app/(main)/[locale]/layout.tsx | 2 + app/(main)/[locale]/login/page.tsx | 4 +- app/(main)/admin/layout.tsx | 2 + app/(main)/layout.tsx | 4 +- app/desktop.css | 3 +- app/globals.css | 137 ++++++- app/manifest.ts | 2 +- components/admin/admin-shell.tsx | 4 + components/boot-skeleton/admin-skeleton.tsx | 3 + .../boot-skeleton/calendar-skeleton.tsx | 2 +- components/boot-skeleton/files-skeleton.tsx | 2 +- components/boot-skeleton/login-skeleton.tsx | 2 +- components/boot-skeleton/mail-skeleton.tsx | 2 +- components/boot-skeleton/pro-skeleton.tsx | 2 + .../boot-skeleton/settings-skeleton.tsx | 7 +- components/calendar/calendar-toolbar.tsx | 2 +- components/calendar/event-modal.tsx | 2 +- components/calendar/task-modal.tsx | 2 +- components/contacts/contact-detail.tsx | 6 +- components/contacts/contact-form.tsx | 2 +- components/contacts/contact-group-detail.tsx | 2 +- components/contacts/contact-group-form.tsx | 2 +- components/email/email-composer.tsx | 1 + components/email/email-viewer.tsx | 4 +- components/files/preview-shell.tsx | 1 + components/layout/inline-app-view.tsx | 2 +- components/layout/navigation-rail.tsx | 1 + components/layout/window-controls-host.tsx | 29 ++ components/layout/window-menu-bar.tsx | 100 ++++++ components/layout/window-title-bar.tsx | 138 ------- components/plugins/app-side-panel.tsx | 1 + components/plugins/plugin-side-panels.tsx | 2 + components/pro/pro-tab-bar.tsx | 1 + components/settings/settings-app.tsx | 4 +- components/ui/dialog.tsx | 4 +- integration/tests/23-do-pwa-windows.spec.ts | 38 +- lib/__tests__/window-controls-host.test.tsx | 340 ++++++++++++++++++ lib/__tests__/window-title-bar.test.tsx | 221 ------------ lib/desktop/boot.ts | 3 +- lib/window-controls-overlay.ts | 126 ++++++- locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/et/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/th/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/vi/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- 70 files changed, 843 insertions(+), 459 deletions(-) create mode 100644 components/layout/window-controls-host.tsx create mode 100644 components/layout/window-menu-bar.tsx delete mode 100644 components/layout/window-title-bar.tsx create mode 100644 lib/__tests__/window-controls-host.test.tsx delete mode 100644 lib/__tests__/window-title-bar.test.tsx diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 762f30ab9..3c03bd9be 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -38,6 +38,7 @@ import { MainWindowOnly } from "@/components/popout/main-window-only"; import { PopoutHost } from "@/components/popout/popout-host"; import { WindowSyncHost } from "@/components/popout/window-sync-host"; import { DesktopHost } from "@/components/desktop/desktop-host"; +import { WindowMenuBar } from "@/components/layout/window-menu-bar"; import { IS_LITE } from "@/lib/lite"; import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; @@ -90,6 +91,7 @@ export default async function LocaleLayout({ {!IS_LITE && } {!IS_LITE && } + {!IS_LITE && } {!IS_LITE && } diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index bf9b69c44..52d6f4be0 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1208,7 +1208,7 @@ function LoginPageContent() { return (
{/* Theme toggle, hidden while the theme policy locks the mode (#1280) */} -