From 8af4f09920c212d10719e27898a2fdbafb42686e Mon Sep 17 00:00:00 2001 From: cyh Date: Mon, 13 Jul 2026 18:50:16 +0800 Subject: [PATCH] fix(confirm): defer iframe focus check until approval click --- src/confirmSurface.ts | 20 +++++++++++++++----- src/protocolV2.test.ts | 6 +++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/confirmSurface.ts b/src/confirmSurface.ts index 22d81ad..5938040 100644 --- a/src/confirmSurface.ts +++ b/src/confirmSurface.ts @@ -42,10 +42,15 @@ const MIN_CONFIRM_HEIGHT = 220 const MAX_PARENT_SURFACE_AGE_MS = 60_000 const MAX_PARENT_SURFACE_FUTURE_SKEW_MS = 1_000 -export function evaluateConfirmSurface(snapshot: ConfirmSurfaceSnapshot): ConfirmSurfaceDecision { +export function evaluateConfirmSurface( + snapshot: ConfirmSurfaceSnapshot, + options: { requireFocus?: boolean } = {}, +): ConfirmSurfaceDecision { if (snapshot.uiShowPending) return { ok: false, code: "sandbox_not_visible", detail: "ui show is still pending" } if (!snapshot.documentVisible) return { ok: false, code: "sandbox_not_visible", detail: "document is not visible" } - if (!snapshot.documentFocused) return { ok: false, code: "sandbox_not_visible", detail: "document is not focused" } + if ((options.requireFocus ?? true) && !snapshot.documentFocused) { + return { ok: false, code: "sandbox_not_visible", detail: "document is not focused" } + } if (snapshot.frameWidth < MIN_CONFIRM_WIDTH || snapshot.frameHeight < MIN_CONFIRM_HEIGHT) { return { ok: false, code: "sandbox_not_visible", detail: "sandbox frame is too small" } } @@ -229,12 +234,14 @@ export function monitorConfirmSurface(input: { parent: parseParentConfirmSurface(input.parentSurface?.()), }) - const assertCurrent = (): void => { - const decision = evaluateConfirmSurface(snapshot()) + const assertSurface = (requireFocus: boolean): void => { + const decision = evaluateConfirmSurface(snapshot(), { requireFocus }) if (!decision.ok) throw new RpcError(decision.code, decision.detail, true) for (const warning of decision.warnings ?? []) console.warn(`[vault-sandbox] Confirm surface advisory: ${warning}`) } + const assertCurrent = (): void => assertSurface(true) + const ready = new Promise((resolve, reject) => { resolveReady = resolve rejectReady = reject @@ -244,7 +251,10 @@ export function monitorConfirmSurface(input: { if (readySettled || disposed) return readySettled = true try { - assertCurrent() + // An embedded document only gains focus after the user taps inside it. + // Visibility can enable the button; the click path re-checks focus + // synchronously before starting WebAuthn. + assertSurface(false) resolveReady() } catch (error) { rejectReady(error) diff --git a/src/protocolV2.test.ts b/src/protocolV2.test.ts index f25f175..179cccf 100644 --- a/src/protocolV2.test.ts +++ b/src/protocolV2.test.ts @@ -768,6 +768,7 @@ describe("confirm surface gate", () => { code: "sandbox_not_visible", detail: "document is not focused", }) + expect(evaluateConfirmSurface({ ...visible, documentFocused: false }, { requireFocus: false })).toEqual({ ok: true }) expect(evaluateConfirmSurface({ ...visible, frameWidth: 200 })).toEqual({ ok: false, code: "sandbox_not_visible", @@ -908,9 +909,10 @@ describe("confirm surface gate", () => { const cardShell = { nodeName: "MAIN" } as Element let observerCallback: IntersectionObserverCallback | undefined let disconnected = false + let focused = false vi.stubGlobal("document", { visibilityState: "visible", - hasFocus: () => true, + hasFocus: () => focused, documentElement: cardShell, body: cardShell, }) @@ -943,6 +945,8 @@ describe("confirm surface gate", () => { const lease = monitorConfirmSurface({ uiShowPending: () => false, visibilityTarget: cardShell }) await expect(lease.ready).resolves.toBeUndefined() + expect(() => lease.assertCurrent()).toThrow(/not focused/) + focused = true expect(() => lease.assertCurrent()).not.toThrow() observerCallback?.(