From 11ec51dfb853e4bc16da7c1c1f9a677962fe0956 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 18 Jul 2026 22:31:27 +0800 Subject: [PATCH] fix(extension): dismiss overlay on borrow timeout Send borrow-cancel when the confirmation timeout denies, and add a timer fallback so auto-deny does not depend only on CSS transitionend. Co-authored-by: Cursor --- .../src/content/BorrowConfirmationOverlay.tsx | 35 +++++++++++------ .../BorrowConfirmationOverlay.test.tsx | 39 +++++++++++++++++-- apps/extension/src/entrypoints/background.ts | 7 ++-- .../__tests__/borrow-confirmation.test.ts | 13 ++++++- .../src/tools/borrow-confirmation.ts | 35 ++++++++++------- 5 files changed, 95 insertions(+), 34 deletions(-) diff --git a/apps/extension/src/content/BorrowConfirmationOverlay.tsx b/apps/extension/src/content/BorrowConfirmationOverlay.tsx index 9258505..6bb915f 100644 --- a/apps/extension/src/content/BorrowConfirmationOverlay.tsx +++ b/apps/extension/src/content/BorrowConfirmationOverlay.tsx @@ -62,6 +62,9 @@ function BorrowToastStack({ children }: { children: React.ReactNode }) { ); } +/** Matches BorrowProgressBar / CountdownRing transition duration (duration-1000). */ +const PROGRESS_TRANSITION_MS = 1000; + function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; isModal: boolean }) { const { t } = useTranslation("extension"); const totalSeconds = Math.ceil(request.timeoutMs / 1000); @@ -74,17 +77,6 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i onAllowRef.current = request.onAllow; onDenyRef.current = request.onDeny; - useEffect(() => { - if (secondsLeft <= 0) { - if (!settledRef.current) { - setAwaitingAutoDeny(true); - } - return; - } - const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000); - return () => clearTimeout(id); - }, [secondsLeft]); - function triggerAllow() { if (settledRef.current) return; settledRef.current = true; @@ -105,6 +97,27 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i setTimeout(() => onDenyRef.current(), EXIT_ANIMATION_MS); } + useEffect(() => { + if (secondsLeft <= 0) { + if (!settledRef.current) { + setAwaitingAutoDeny(true); + } + return; + } + const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000); + return () => clearTimeout(id); + }, [secondsLeft]); + + // Prefer transitionend for visual sync, but fall back to a timer so + // background tabs / suppressed CSS transitions still auto-deny. + useEffect(() => { + if (!awaitingAutoDeny || settledRef.current) return; + const id = setTimeout(() => { + handleDeny(); + }, PROGRESS_TRANSITION_MS); + return () => clearTimeout(id); + }, [awaitingAutoDeny]); + const progress = secondsLeft / totalSeconds; const truncatedTitle = request.tabTitle.length > 30 ? `${request.tabTitle.slice(0, 30)}…` : request.tabTitle; diff --git a/apps/extension/src/content/__tests__/BorrowConfirmationOverlay.test.tsx b/apps/extension/src/content/__tests__/BorrowConfirmationOverlay.test.tsx index 04be736..d273ef8 100644 --- a/apps/extension/src/content/__tests__/BorrowConfirmationOverlay.test.tsx +++ b/apps/extension/src/content/__tests__/BorrowConfirmationOverlay.test.tsx @@ -38,9 +38,6 @@ describe("BorrowConfirmationOverlay", () => { }); } expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy(); - await act(async () => { - await vi.advanceTimersByTimeAsync(1000); - }); const progressCircle = container.querySelectorAll("svg circle")[1]; expect(progressCircle).toBeTruthy(); await act(() => { @@ -58,6 +55,42 @@ describe("BorrowConfirmationOverlay", () => { expect(onAllow).not.toHaveBeenCalled(); }); + it("auto-denies via timer fallback when progress transitionend never fires", async () => { + const onAllow = vi.fn(); + const onDeny = vi.fn(); + + render( + , + ); + + for (let i = 0; i < 5; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + } + expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy(); + // No transitionend — the PROGRESS_TRANSITION_MS fallback must still deny. + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + expect(onDeny).toHaveBeenCalledTimes(1); + expect(onAllow).not.toHaveBeenCalled(); + }); + it("only invokes onDeny once when deny is clicked repeatedly", async () => { const onAllow = vi.fn(); const onDeny = vi.fn(); diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index 91c73e3..c1bafb5 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -54,8 +54,7 @@ export default defineBackground(() => { deps: { // Skip every Agent Window when choosing where to render the // overlay — Agent Windows boot on about:blank, which has no - // content script, so sendMessage would fail-open and silently - // allow the borrow without any UI shown. + // content script, so they cannot surface an authorization decision. isAgentWindowId: (windowId) => sessions.findByWindowId(windowId) !== null, // Resolve i18n strings per-borrow so language switches take effect // without re-creating the dispatcher. @@ -82,8 +81,8 @@ export default defineBackground(() => { // The Allow / Deny buttons on the OS notification are the *explicit* // authorization fallback when every candidate user window's content // script was missing (extension just reloaded, page in BFCache, etc.). - // Without this listener those button clicks would land nowhere and we'd - // be back to fail-open-after-sendMessage-failure. + // Without this listener those button clicks would land nowhere and the + // request would only resolve via the fail-closed background timeout. if (typeof chrome.notifications?.onButtonClicked?.addListener === "function") { attachBorrowNotificationButtonHandler({ onButtonClicked: chrome.notifications.onButtonClicked, diff --git a/apps/extension/src/tools/__tests__/borrow-confirmation.test.ts b/apps/extension/src/tools/__tests__/borrow-confirmation.test.ts index 5c4dbe6..6ecfd23 100644 --- a/apps/extension/src/tools/__tests__/borrow-confirmation.test.ts +++ b/apps/extension/src/tools/__tests__/borrow-confirmation.test.ts @@ -437,6 +437,7 @@ describe("requestBorrowConfirmation", () => { expect(notificationId.startsWith(BORROW_NOTIFICATION_PREFIX)).toBe(true); expect(options.type).toBe("basic"); expect(options.title).toMatch(/borrow/i); + expect(options.requireInteraction).toBe(true); expect(notifications.clear).toHaveBeenCalledWith(notificationId); }); @@ -463,10 +464,12 @@ describe("requestBorrowConfirmation", () => { userWindowWithActiveTab({ windowId: 77, tabId: 4242, url: "https://docs.example/" }), ); // Keep sendMessage pending so the request stays live while we click. + // A second call may arrive if the background timeout dismisses the overlay. let resolveSend: (v: { type: string; allowed: boolean }) => void = () => undefined; tabs.sendMessage.mockImplementationOnce( () => new Promise((res) => (resolveSend = res as never)), ); + tabs.sendMessage.mockResolvedValue(undefined); const pending = requestBorrowConfirmation(42, { deps: { tabs, windows, notifications }, @@ -476,7 +479,7 @@ describe("requestBorrowConfirmation", () => { // Simulate user clicking the OS notification. for (const l of listeners) l(notificationId); - await vi.runAllTimersAsync(); + await Promise.resolve(); expect(windows.update).toHaveBeenCalledWith(77, { focused: true }); // Finish the borrow so the test resolves cleanly. @@ -491,12 +494,20 @@ describe("requestBorrowConfirmation", () => { userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }), ); tabs.sendMessage.mockImplementationOnce(() => new Promise(() => undefined)); + tabs.sendMessage.mockResolvedValueOnce(undefined); const pending = requestBorrowConfirmation(42, { deps: { tabs, windows, notifications }, }); await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS); expect(await pending).toBe(false); + // Timeout must dismiss any in-flight overlay so Allow cannot linger. + expect(tabs.sendMessage).toHaveBeenCalledTimes(2); + const cancelMessage = tabs.sendMessage.mock.calls[1][1] as { + type: string; + requestId: string; + }; + expect(cancelMessage.type).toBe("borrow-cancel"); }); it("dismisses the pending overlay and denies when aborted", async () => { diff --git a/apps/extension/src/tools/borrow-confirmation.ts b/apps/extension/src/tools/borrow-confirmation.ts index bd72f74..060f935 100644 --- a/apps/extension/src/tools/borrow-confirmation.ts +++ b/apps/extension/src/tools/borrow-confirmation.ts @@ -28,8 +28,9 @@ export const CONFIRMATION_TIMEOUT_MS = 5000; const EXIT_ANIMATION_MS = 150; /** Matches BorrowConfirmationOverlay progress ring/bar transition (duration-1000). */ export const PROGRESS_TRANSITION_MS = 1000; -// UI auto-allows after countdown + progress transition + exit fade. Background -// timeout must not fire before that chain completes or deny is ignored. +// UI auto-denies after countdown + progress transition + exit fade. Background +// timeout must not fire before that chain completes or the UI decision races +// with an already-settled deny. export const BACKGROUND_TIMEOUT_MS = CONFIRMATION_TIMEOUT_MS + PROGRESS_TRANSITION_MS + EXIT_ANIMATION_MS + 500; @@ -441,21 +442,24 @@ export async function requestBorrowConfirmation( resolve(allowed); }; - const onAbort = () => { - if (activeMessageTabId !== null) { - const cancelMessage: BorrowCancelMessage = { - type: "borrow-cancel", + const dismissPendingOverlay = () => { + if (activeMessageTabId === null) return; + const cancelMessage: BorrowCancelMessage = { + type: "borrow-cancel", + requestId, + }; + void Promise.resolve(tabsApi.sendMessage(activeMessageTabId, cancelMessage)).catch((err) => { + console.debug("[bsk borrow] cancel message failed", { + tabId, + messageTabId: activeMessageTabId, requestId, - }; - void tabsApi.sendMessage(activeMessageTabId, cancelMessage).catch((err) => { - console.debug("[bsk borrow] cancel message failed", { - tabId, - messageTabId: activeMessageTabId, - requestId, - error: err instanceof Error ? err.message : String(err), - }); + error: err instanceof Error ? err.message : String(err), }); - } + }); + }; + + const onAbort = () => { + dismissPendingOverlay(); settle(false); }; @@ -470,6 +474,7 @@ export async function requestBorrowConfirmation( tabId, messageTabId: activeMessageTabId, }); + dismissPendingOverlay(); settle(false); }, BACKGROUND_TIMEOUT_MS);