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 5cb31e366..69cbd18c0 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -406,6 +406,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;