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] 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);