From 7f50113b90ade8ee2803ded757aed5f188fa14dd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:57:04 +0100 Subject: [PATCH] fix: ask to clear a desktop app lock this profile has no PIN for --- lib/app-lock/__tests__/controller.test.ts | 27 +++++++++++++++++++++++ lib/app-lock/controller.ts | 14 ++++++++++++ lib/desktop/types.ts | 2 ++ 3 files changed, 43 insertions(+) diff --git a/lib/app-lock/__tests__/controller.test.ts b/lib/app-lock/__tests__/controller.test.ts index ad3bd3121..c8195bc62 100644 --- a/lib/app-lock/__tests__/controller.test.ts +++ b/lib/app-lock/__tests__/controller.test.ts @@ -84,6 +84,7 @@ function bridge(overrides: Record = {}) { prove: vi.fn(async () => true), disable: vi.fn(async () => true), forget: vi.fn(async () => true), + clearOrphan: 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 })), @@ -439,6 +440,32 @@ describe('the store when a tab inherits it without the key', () => { }); describe('the desktop app', () => { + const settled = () => new Promise((resolve) => setTimeout(resolve, 50)); + + it('asks main to clear a lock this profile has no PIN for', async () => { + const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) }); + bootAppLock(); + await settled(); + expect(appLock.clearOrphan).toHaveBeenCalledTimes(1); + expect(appLock.forget).not.toHaveBeenCalled(); + }); + + it('leaves main alone when there is a vault, so a real lock is never questioned', async () => { + const raw = await createVault(PIN); + raw.fill(0); + const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) }); + bootAppLock(); + await settled(); + expect(appLock.clearOrphan).not.toHaveBeenCalled(); + }); + + it('asks nothing when main has no lock', async () => { + const appLock = bridge({ status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false })) }); + bootAppLock(); + await settled(); + expect(appLock.clearOrphan).not.toHaveBeenCalled(); + }); + it('a PIN unlock proves itself to main, and nothing can just say unlocked', async () => { const appLock = bridge(); await enableAppLock(PIN); diff --git a/lib/app-lock/controller.ts b/lib/app-lock/controller.ts index 2922acbaf..c6f69c16b 100644 --- a/lib/app-lock/controller.ts +++ b/lib/app-lock/controller.ts @@ -191,6 +191,7 @@ export function bootAppLock(): void { return; } markDocument(false); + void clearOrphanedDesktopLock(); // The look already came back empty: nothing to hold back for. if (headScriptFoundNoVault() && !setupNeeded()) return; hold = newHold(); @@ -203,6 +204,19 @@ export function bootAppLock(): void { void settleNoFile(); } +/** + * Main keeps a lock and there is no vault for it: a same-origin plugin frame enrolled one, or + * this profile's storage went. Nothing here could ever prove it, so tray, toasts and titles would + * stay blank for good. No page can lift main's lock on its word, so main asks the user. + */ +async function clearOrphanedDesktopLock(): Promise { + const member = getDesktopBridge()?.appLock; + if (!member?.clearOrphan) return; + const status = await member.status().catch(() => null); + if (!status?.enrolled || await vaultExists() || enabling || readAppLockFile().on) return; + await member.clearOrphan().catch(() => false); +} + function lockedFromNow(): void { writeAppLockFile({ ...readAppLockFile(), on: true, locked: true }); setLockedHere(true); diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 2e80e6479..643b9705d 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -367,6 +367,8 @@ export interface DesktopAppLockBridge { disable: (proof: Uint8Array) => Promise; /** The PIN is lost, or everyone signed out. With no proof, main asks the user natively. */ forget: (proof?: Uint8Array) => Promise; + /** Main keeps a lock and this profile has no PIN for it. Main asks the user natively, once a run. */ + clearOrphan?: () => Promise; /** Through safeStorage, for this origin only. Null where the OS has no real key store. */ wrap: (data: Uint8Array) => Promise; unwrap: (data: Uint8Array) => Promise;