From c844f2a5f3e6cb9fc98a0231832df21ee09cdc90 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:12:43 +0100 Subject: [PATCH 1/5] fix: finish a wipe in the desktop app, which never answers about shown notifications --- .../__tests__/system-notification.test.ts | 34 ++++++++++++++++++ lib/calendar-reminders/system-notification.ts | 6 +++- lib/notifications/shown.ts | 24 +++++++++++++ lib/offline/__tests__/device-wipe.test.ts | 35 +++++++++++++++++++ lib/offline/device-wipe.ts | 12 +++++-- 5 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 lib/calendar-reminders/__tests__/system-notification.test.ts create mode 100644 lib/notifications/shown.ts diff --git a/lib/calendar-reminders/__tests__/system-notification.test.ts b/lib/calendar-reminders/__tests__/system-notification.test.ts new file mode 100644 index 000000000..761d401bb --- /dev/null +++ b/lib/calendar-reminders/__tests__/system-notification.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { closeSystemReminders } from '../system-notification'; + +describe('closing reminders', () => { + afterEach(() => { + vi.unstubAllGlobals(); + delete (navigator as { serviceWorker?: unknown }).serviceWorker; + }); + + function withRegistration(getNotifications: (filter: { tag: string }) => Promise): void { + vi.stubGlobal('Notification', { permission: 'granted' }); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ getNotifications }) }, + }); + } + + it('closes what is shown', async () => { + const close = vi.fn(); + withRegistration(async () => [{ close } as unknown as Notification]); + await closeSystemReminders(['a']); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("doesn't wait for good on a shell that never answers", async () => { + // Electron leaves getNotifications() pending for ever. + withRegistration(() => new Promise(() => {})); + const answer = await Promise.race([ + closeSystemReminders(['a', 'b']).then(() => 'finished'), + new Promise((resolve) => setTimeout(() => resolve('hung'), 6_000)), + ]); + expect(answer).toBe('finished'); + }, 10_000); +}); diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts index 2427f7c19..26dd022d1 100644 --- a/lib/calendar-reminders/system-notification.ts +++ b/lib/calendar-reminders/system-notification.ts @@ -5,6 +5,8 @@ * tab asks. */ +import { shownNotifications } from '@/lib/notifications/shown'; + export interface SystemReminder { key: string; title: string; @@ -65,7 +67,9 @@ export async function closeSystemReminders(keys: readonly string[]): Promise, + filter?: GetNotificationOptions, + waitMs = SHOWN_WAIT_MS, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + registration.getNotifications(filter), + new Promise((resolve) => { timer = setTimeout(() => resolve(null), waitMs); }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index d0b44dd22..bf460e7cf 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -1261,6 +1261,41 @@ describe('what else goes with a wipe', () => { }); }); +describe('a shell that never answers about shown notifications', () => { + // Electron has no answer to getNotifications(): the promise stays pending for good. + beforeEach(() => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + const registration = { + getNotifications: () => new Promise(() => {}), + pushManager: { getSubscription: async () => null }, + }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { getRegistration: async () => registration, controller: null } }); + }); + + afterEach(() => { + delete (navigator as { serviceWorker?: unknown }).serviceWorker; + }); + + const ended = (work: Promise) => Promise.race([ + work.then(() => 'finished'), + new Promise((resolve) => setTimeout(() => resolve('hung'), 20_000)), + ]); + + it('a wipe of the whole device still gets to the keys and the files', async () => { + const before = purge.files; + expect(await ended(wipe.wipeWholeDevice('user'))).toBe('finished'); + expect(purge.files).toBeGreaterThan(before); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + + it('a wipe of one account gets past them too, to its sign-out', async () => { + expect(await ended(wipe.wipeAccountFromDevice(ALICE, 'admin'))).toBe('finished'); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(useAccountStore.getState().accounts.some((account) => account.id === ALICE)).toBe(false); + }); +}); + describe('the session timers', () => { const timerSignOut = () => useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index faface568..74b2777bf 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -52,6 +52,7 @@ import { isAppLockedNow } from '@/lib/app-lock/state'; import { clearSealed, VAULT_DB } from '@/lib/app-lock/vault'; import { clearRescuedDrafts } from '@/lib/popout/rescued-drafts'; import { isPopoutWindow } from '@/lib/popout/window-role'; +import { shownNotifications } from '@/lib/notifications/shown'; import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; import { ACCOUNT_STORAGE_KEYS, onBeforeSignOut } from '@/lib/sign-out-cleanup'; import { @@ -101,6 +102,8 @@ export const CHECK_IN_EVERY_MS = 60 * 60 * 1000; const TICK_MS = 60 * 1000; /** How long a wipe waits to tell the instance before it goes on: it tries again at the next contact. */ const REPORT_BUDGET_MS = 3_000; +/** How long a wipe waits for the desktop app's main process: the keys come after this. */ +const SHELL_FILES_BUDGET_MS = 10_000; /** How long a wipe waits for one database a running plugin frame holds open. */ const DATABASE_BUDGET_MS = 2_000; @@ -208,7 +211,11 @@ async function clearShellFiles(): Promise { clipboard?: { clearFiles?: () => void | Promise }; } | null; bridge?.os?.clearSurfaces?.(); - await bridge?.clipboard?.clearFiles?.(); + let timer: ReturnType | undefined; + await Promise.race([ + Promise.resolve(bridge?.clipboard?.clearFiles?.()), + new Promise((resolve) => { timer = setTimeout(resolve, SHELL_FILES_BUDGET_MS); }), + ]).finally(() => clearTimeout(timer)); } /** A running frame is gone at the next load. Before any plugin starts again. */ @@ -256,7 +263,8 @@ async function pushRegistration(): Promise { /** 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(); + if (!registration?.getNotifications) return; + for (const notification of await shownNotifications(registration) ?? []) notification.close(); } /** The subscription is the browser's, shared by every account on this origin. */ From 66c6b61853da4add15ebf27adc911e8d890b4488 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:31:16 +0100 Subject: [PATCH 2/5] fix: keep downloaded mail out of the browser's HTTP cache --- lib/desktop/types.ts | 2 ++ lib/jmap/__tests__/no-http-cache.test.ts | 38 ++++++++++++++++++++++++ lib/jmap/client.ts | 5 +++- lib/sign-out-cleanup.ts | 5 +++- lib/store/__tests__/transport.test.ts | 15 ++++++++++ lib/store/engine/transport.ts | 3 +- 6 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 lib/jmap/__tests__/no-http-cache.test.ts diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 2e80e6479..217902572 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -655,6 +655,8 @@ export interface BulwarkDesktopBridge { /** Absent on older shells. */ certificates?: DesktopCertificatesBridge; appLock?: DesktopAppLockBridge; + /** Absent on an older shell. */ + httpCache?: { clear: () => Promise }; localImport?: DesktopLocalImportBridge; privacy?: DesktopPrivacyBridge; } diff --git a/lib/jmap/__tests__/no-http-cache.test.ts b/lib/jmap/__tests__/no-http-cache.test.ts new file mode 100644 index 000000000..a12f5a7d9 --- /dev/null +++ b/lib/jmap/__tests__/no-http-cache.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../client'; + +/** The browser's cache isn't encrypted, and Stalwart lets it keep a download for a year. */ +describe('what the client fetches stays out of the HTTP cache', () => { + afterEach(() => vi.unstubAllGlobals()); + + function clientWithFetch() { + const seen: Array<{ url: string; cache?: RequestCache }> = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + seen.push({ url: String(url), cache: init?.cache }); + return new Response('raw message', { status: 200 }); + })); + const client = JMAPClient.withBearer('https://mail.example.test', 'token', 'alice'); + const fields = client as unknown as { downloadUrl: string; accountId: string }; + fields.downloadUrl = 'https://mail.example.test/d/{accountId}/{blobId}/{name}?accept={type}'; + fields.accountId = 'a'; + return { client, seen }; + } + + it('a blob, as attachments and inline images use it', async () => { + const { client, seen } = clientWithFetch(); + await client.fetchBlob('b1', 'photo.png', 'image/png'); + expect(seen).toEqual([expect.objectContaining({ cache: 'no-store' })]); + }); + + it('a raw message for an export or a move', async () => { + const { client, seen } = clientWithFetch(); + await client.fetchBlobForTransfer('b2', 'a'); + expect(seen).toEqual([expect.objectContaining({ cache: 'no-store' })]); + }); + + it('bytes for a preview', async () => { + const { client, seen } = clientWithFetch(); + await client.fetchBlobArrayBuffer('b3', 'report.pdf', 'application/pdf'); + expect(seen).toEqual([expect.objectContaining({ cache: 'no-store' })]); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 2ac9dd25f..6b445018b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1868,7 +1868,8 @@ export class JMAPClient implements IJMAPClient { const stopWatching = onConnectionsReset(giveUp); try { - const response = await fetch(url, { ...withJmapCredentials(init), headers, signal: controller.signal }); + // The browser's cache isn't encrypted, and Stalwart lets it keep a download for a year. + const response = await fetch(url, { ...withJmapCredentials(init), cache: 'no-store', headers, signal: controller.signal }); if (stopWatching) setTimeout(stopWatching, timeoutMs); return response; } catch (error) { @@ -2022,6 +2023,7 @@ export class JMAPClient implements IJMAPClient { if (response.redirected && response.status === 401) { return fetch(response.url, withJmapCredentials({ method: 'GET', + cache: 'no-store', headers: { 'Authorization': this.authHeader }, })); } @@ -2034,6 +2036,7 @@ export class JMAPClient implements IJMAPClient { return fetch(response.url, withJmapCredentials({ method: 'GET', + cache: 'no-store', headers: { 'Authorization': this.authHeader }, })); } diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index f364514a3..4c8227e9d 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -2,7 +2,7 @@ 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'; -import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; +import { clearDesktopSurfaces, getDesktopBridge } from '@/lib/desktop/bridge'; import { sweepPrivateExports } from '@/lib/mail-export/sinks'; import { purgeStoreFiles } from '@/lib/store/client/purge'; @@ -153,6 +153,9 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { // Files and links on their way into a draft or an import. clearHandoffs(); clearDesktopSurfaces(); + // Nothing new goes in the HTTP cache, but a profile from an older version has mail in it. + // A browser gives a page no way to empty it. + void getDesktopBridge()?.httpCache?.clear().catch(() => {}); for (const cleanup of signedOutCleanups) { try { cleanup(options); diff --git a/lib/store/__tests__/transport.test.ts b/lib/store/__tests__/transport.test.ts index 780837583..76e6c5ed9 100644 --- a/lib/store/__tests__/transport.test.ts +++ b/lib/store/__tests__/transport.test.ts @@ -35,6 +35,21 @@ function transportWith(respond: (init: RequestInit) => Response | Promise vi.useRealTimers()); +describe('the HTTP cache', () => { + // Stalwart answers a download with `private, immutable, max-age=31536000`, and the browser's + // cache isn't encrypted: without this, a whole message sits in it in the clear for a year. + it('never gets a download or an answer', async () => { + const seen: Array = []; + const transport = transportWith((init) => { + seen.push(init); + return new Response('{"methodResponses":[]}', { status: 200 }); + }); + await transport.download('a', 'b', 'message/rfc822', 'message.eml'); + await transport.request([], []); + expect(seen.map((init) => init.cache)).toEqual(['no-store', 'no-store']); + }); +}); + describe('downloads', () => { it('gives a body piece by piece and adds them up for a plain download', async () => { const parts = [new Uint8Array([1, 2]), new Uint8Array([3])]; diff --git a/lib/store/engine/transport.ts b/lib/store/engine/transport.ts index ec2a6288c..ac17e7548 100644 --- a/lib/store/engine/transport.ts +++ b/lib/store/engine/transport.ts @@ -128,7 +128,8 @@ export class FetchTransport implements JmapTransport { if (!header) throw new TransportError('auth', 'No credentials yet'); if (this.aborted) throw new TransportError('network', 'The store is closing'); try { - return await doFetch(url, { ...init, signal: transfer.signal, headers: { ...init.headers, Authorization: header } }); + // The browser's cache isn't encrypted, and a download may stay in it for a year. + return await doFetch(url, { ...init, cache: 'no-store', signal: transfer.signal, headers: { ...init.headers, Authorization: header } }); } catch (error) { throw new TransportError('network', messageOf(error, 'Network failure'), undefined, undefined, undefined, transfer.timedOut()); } From 88d64b7f95a75dfed0d817ddbfe44653b5a4d5fd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:15:31 +0100 Subject: [PATCH 3/5] fix: protect the keys to the mail on this device for real The review found the store key readable from a copy of the disk. The desktop app only added its OS layer with the lock on, the record from before a lock stayed in LevelDB and opened the mail with no PIN, any frame could unwrap the PIN record and guess at it, a lock left the key in the renderer's memory, and a store waiting after a refused login went to whoever signed in next under the same username@host. - The desktop app wraps the store key with safeStorage, lock or no lock. A record without the layer gets it at its next open. A refusal is said out loud, and only a computer with no key store goes without, which settings say. So do they for a browser, which has no such layer. - A record that gained layers, and every record after a changed PIN, asks for a new key: `sqlite3_backup` into a new file under a new key while the store keeps working, one write to the record, then the old file goes. A changed PIN makes a new lock key, since the old PIN's record stays on disk too. The passkey and OS unlock go with the old key. - `wrap` and `unwrap` come through `claim()`, and the key under the PIN takes a secret from main, one counted trip per guess. - A lock on the desktop ends the renderer rather than reload in it. - A sign-in compares the waiting store's JMAP account and server first, and wipes on a mismatch. The account id keeps its form: every store, setting and server record of an account is keyed by it. - The 30 days also run in an app that stays open. Changes an existing assertion: turning the lock off now keeps the safeStorage layer, by decision, so that test expects it to stay. --- .../admin/org/sections/offline-section.tsx | 6 +- .../__tests__/key-protection-notes.test.tsx | 50 ++++++ components/offline/key-protection-notes.tsx | 49 ++++++ components/offline/offline-host.tsx | 9 ++ components/offline/offline-settings.tsx | 2 + components/settings/app-lock-settings.tsx | 10 +- lib/app-lock/__tests__/controller.test.ts | 104 ++++++++++++- lib/app-lock/__tests__/vault.test.ts | 105 ++++++++++++- lib/app-lock/controller.ts | 145 ++++++++++++++---- lib/app-lock/crypto.ts | 19 +++ lib/app-lock/shell.ts | 51 ++++++ lib/app-lock/vault.ts | 121 +++++++++++---- lib/desktop/types.ts | 20 ++- lib/offline/__tests__/device-wipe.test.ts | 38 +++++ lib/offline/__tests__/store-owner.test.ts | 60 ++++++++ lib/offline/device-wipe.ts | 3 +- lib/offline/offline-mode.ts | 30 +++- lib/offline/state.ts | 63 ++++++++ lib/sign-out-cleanup.ts | 1 + lib/store/__tests__/host.test.ts | 85 +++++++++- lib/store/__tests__/key-layers.test.ts | 108 +++++++++++++ lib/store/__tests__/page-side.test.ts | 88 +++++++++++ lib/store/adapters/wasm.ts | 51 +++++- lib/store/area.ts | 4 +- lib/store/client/api.ts | 73 ++++++++- lib/store/client/keys.ts | 101 +++++++++++- lib/store/client/purge.ts | 12 +- lib/store/client/store-client.ts | 9 +- lib/store/worker/host.ts | 104 +++++++++++++ lib/store/worker/protocol.ts | 2 + lib/store/worker/worker.ts | 8 + locales/ar/common.json | 12 +- locales/ca/common.json | 12 +- locales/cs/common.json | 12 +- locales/da/common.json | 12 +- locales/de/common.json | 12 +- locales/en/common.json | 12 +- locales/es/common.json | 12 +- locales/et/common.json | 12 +- locales/fa/common.json | 12 +- locales/fr/common.json | 12 +- locales/he/common.json | 12 +- locales/hu/common.json | 12 +- locales/it/common.json | 12 +- locales/ja/common.json | 12 +- locales/ko/common.json | 12 +- locales/lv/common.json | 12 +- locales/mn/common.json | 12 +- locales/nb/common.json | 12 +- locales/nl/common.json | 12 +- locales/pl/common.json | 12 +- locales/pt/common.json | 12 +- locales/ro/common.json | 12 +- locales/ru/common.json | 12 +- locales/sk/common.json | 12 +- locales/th/common.json | 12 +- locales/tr/common.json | 12 +- locales/uk/common.json | 12 +- locales/vi/common.json | 12 +- locales/zh-TW/common.json | 12 +- locales/zh/common.json | 12 +- stores/auth-store.ts | 6 +- 62 files changed, 1734 insertions(+), 163 deletions(-) create mode 100644 components/offline/__tests__/key-protection-notes.test.tsx create mode 100644 components/offline/key-protection-notes.tsx create mode 100644 lib/app-lock/shell.ts create mode 100644 lib/offline/__tests__/store-owner.test.ts create mode 100644 lib/store/__tests__/key-layers.test.ts diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 9a806ccbc..18f63f935 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -117,8 +117,10 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {

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 - against someone using the unlocked browser or app. The limits above can only lower what people pick: where several + with calendar, contacts and files, encrypted on the device. In a browser the key sits in the same profile, so this doesn't stop someone + who copies its files: use full-disk encryption. In the desktop app the key also takes that person's login on + that computer. Neither stops someone using the unlocked browser or app. An app lock adds a PIN, which someone with + a copy can guess at their own speed, so ask for a long one. 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. 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. diff --git a/components/offline/__tests__/key-protection-notes.test.tsx b/components/offline/__tests__/key-protection-notes.test.tsx new file mode 100644 index 000000000..53ac50a25 --- /dev/null +++ b/components/offline/__tests__/key-protection-notes.test.tsx @@ -0,0 +1,50 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOfflineStore } from '@/lib/offline/state'; +import { KeyProtectionNotes } from '../key-protection-notes'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), +})); + +function withShell(wrap: boolean): void { + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { + version: 1, + platform: 'linux', + appLock: { status: async () => ({ wrap }) }, + }; +} + +describe('what settings say about the key', () => { + beforeEach(() => useOfflineStore.setState({ keyUnprotected: false, rekey: {} })); + afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + }); + + it('a browser says it has no layer of the OS', async () => { + await act(async () => { render(); }); + expect(screen.getByText('browser_note')).toBeTruthy(); + }); + + it('the desktop app with a real key store says nothing', async () => { + withShell(true); + await act(async () => { render(); }); + expect(screen.queryByTestId('key-protection-notes')).toBeNull(); + }); + + it('Linux with no secret service says secrets are only obfuscated', async () => { + withShell(false); + await act(async () => { render(); }); + expect(screen.getByText('no_secure_storage')).toBeTruthy(); + expect(screen.queryByText('browser_note')).toBeNull(); + }); + + it('says out loud when the key store refused, and how a re-encryption goes', async () => { + withShell(true); + useOfflineStore.setState({ keyUnprotected: true, rekey: { a: 40, b: 'failed' } }); + await act(async () => { render(); }); + expect(screen.getByText('wrap_failed')).toBeTruthy(); + expect(screen.getByText('rekey_failed')).toBeTruthy(); + expect(screen.getByText('rekey_progress:{"percent":40}')).toBeTruthy(); + }); +}); diff --git a/components/offline/key-protection-notes.tsx b/components/offline/key-protection-notes.tsx new file mode 100644 index 000000000..ee5a1a36c --- /dev/null +++ b/components/offline/key-protection-notes.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { useOfflineStore } from '@/lib/offline/state'; + +/** + * What protects the key to the mail on this device, where that's less than one would think, and + * how a re-encryption is getting on. `browser`: also says what a browser can't do. The app lock's + * own settings have said that already. + */ +export function KeyProtectionNotes({ browser = false }: { browser?: boolean }) { + const t = useTranslations('keyProtection'); + const keyUnprotected = useOfflineStore((state) => state.keyUnprotected); + const rekey = useOfflineStore((state) => state.rekey); + const [bridge, setBridge] = useState<'none' | 'wraps' | 'obfuscates'>('wraps'); + + useEffect(() => { + const member = getDesktopBridge()?.appLock; + if (!member) { + setBridge('none'); + return; + } + let live = true; + void member.status().then((status) => { + if (live) setBridge(status.wrap ? 'wraps' : 'obfuscates'); + }).catch(() => {}); + return () => { + live = false; + }; + }, []); + + const states = Object.values(rekey); + const running = states.filter((state): state is number => typeof state === 'number'); + const lines = [ + bridge === 'obfuscates' ? t('no_secure_storage') : null, + bridge === 'none' && browser ? t('browser_note') : null, + keyUnprotected ? t('wrap_failed') : null, + states.includes('failed') ? t('rekey_failed') : null, + running.length > 0 ? t('rekey_progress', { percent: Math.min(...running) }) : null, + ].filter((line): line is string => line !== null); + if (lines.length === 0) return null; + return ( +

+ {lines.map((line) =>

{line}

)} +
+ ); +} diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index f86d066a3..244db009c 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -27,6 +27,9 @@ export function OfflineHost() { const tStorage = useTranslations('offlineStorage'); const tStorageRef = useRef(tStorage); tStorageRef.current = tStorage; + const tKey = useTranslations('keyProtection'); + const tKeyRef = useRef(tKey); + tKeyRef.current = tKey; const router = useRouter(); const routerRef = useRef(router); routerRef.current = router; @@ -89,6 +92,12 @@ export function OfflineHost() { case 'store-unavailable': toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 }); break; + case 'key-unprotected': + toast.warning(tKeyRef.current('wrap_failed'), { duration: 0 }); + break; + case 'rekey-failed': + toast.warning(tKeyRef.current('rekey_failed'), { duration: 0 }); + break; case 'refused-delete': toast.warning(tr('toast.refused_delete')); break; diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 1bfd3706a..0ae50fbc3 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -15,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { AccountStorage } from './account-storage'; import { OutboxList } from './outbox-list'; +import { KeyProtectionNotes } from './key-protection-notes'; import { WipeDeviceSection } from './wipe-device-section'; /** @@ -65,6 +66,7 @@ export function OfflineSettings() {