From 91cec30c6bfe201d7788cd8effa194cdd0f1827a Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Fri, 4 Sep 2026 20:19:02 -0400 Subject: [PATCH 1/2] fix(desktop): guard preview focus restoration --- apps/desktop/src/preview/Manager.test.ts | 108 +++++++++++++++++++++-- apps/desktop/src/preview/Manager.ts | 35 +++++++- 2 files changed, 133 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a7b3afabd3c3..dac4c047dba2 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -116,6 +116,7 @@ const { createFromPath, fromId, getFocusedWebContents, + getFocusedWindow, mkdir, showItemInFolder, webviewSend, @@ -126,6 +127,9 @@ const { createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), + getFocusedWindow: vi.fn<() => Electron.BrowserWindow | null>( + () => ({}) as Electron.BrowserWindow, + ), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), webviewSend: vi.fn(), @@ -134,7 +138,7 @@ const { })); vi.mock("electron", () => ({ - BrowserWindow: browserWindowConstructor, + BrowserWindow: Object.assign(browserWindowConstructor, { getFocusedWindow }), clipboard: { writeImage, }, @@ -466,6 +470,8 @@ describe("PreviewManager", () => { fromId.mockClear(); getFocusedWebContents.mockReset(); getFocusedWebContents.mockReturnValue(null); + getFocusedWindow.mockReset(); + getFocusedWindow.mockReturnValue({} as Electron.BrowserWindow); mkdir.mockClear(); writeFile.mockClear(); showItemInFolder.mockClear(); @@ -3564,6 +3570,13 @@ describe("PreviewManager", () => { withManager((manager) => Effect.gen(function* () { let failKeyDown = false; + let interruptPress = false; + let blockCleanup = false; + const cleanupStarted = Deferred.makeUnsafe(); + let releaseCleanup!: () => void; + const cleanupRelease = new Promise((resolve) => { + releaseCleanup = resolve; + }); let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; const sendCommand = vi.fn(async (method: string, params?: Record) => { if ( @@ -3579,22 +3592,30 @@ describe("PreviewManager", () => { ) { humanInput?.( {}, - { - kind: "key", - key: params["key"], - code: params["code"] ?? "Digit1", - }, + interruptPress + ? { kind: "pointer", x: 400, y: 300, button: 0 } + : { + kind: "key", + key: params["key"], + code: params["code"] ?? "Digit1", + }, ); } + if (blockCleanup && method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp") { + Deferred.doneUnsafe(cleanupStarted, Effect.void); + await cleanupRelease; + } return method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined; }); const restoreFocus = vi.fn(); const focus = vi.fn(); - getFocusedWebContents.mockReturnValue({ + const previousFocused = { id: 7, isDestroyed: () => false, focus: restoreFocus, - } as never); + } as never; + const focusedGuest = { id: 42, isDestroyed: () => false } as never; + getFocusedWebContents.mockReturnValueOnce(previousFocused).mockReturnValue(focusedGuest); fromId.mockReturnValue({ id: 42, isDestroyed: () => false, @@ -3691,6 +3712,8 @@ describe("PreviewManager", () => { expect(sendCommand).toHaveBeenCalledWith("Input.setIgnoreInputEvents", { ignore: false }); sendCommand.mockClear(); + getFocusedWebContents.mockReset(); + getFocusedWebContents.mockReturnValueOnce(previousFocused).mockReturnValue(focusedGuest); failKeyDown = true; const failedPress = yield* Effect.exit(manager.automationPress("tab_input", { key: "y" })); @@ -3716,6 +3739,8 @@ describe("PreviewManager", () => { ).toHaveLength(1); sendCommand.mockClear(); + getFocusedWebContents.mockReset(); + getFocusedWebContents.mockReturnValueOnce(previousFocused).mockReturnValue(focusedGuest); failKeyDown = false; yield* manager.automationPress("tab_input", { key: "!" }); expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", { @@ -3730,6 +3755,73 @@ describe("PreviewManager", () => { unmodifiedText: "!", }); expect(restoreFocus).toHaveBeenCalledTimes(3); + + getFocusedWebContents.mockReset(); + getFocusedWebContents + .mockReturnValueOnce({ id: 7, isDestroyed: () => false, focus: restoreFocus } as never) + .mockReturnValueOnce(null); + getFocusedWindow.mockReturnValue(null); + yield* manager.automationPress("tab_input", { key: "?" }); + expect(restoreFocus).toHaveBeenCalledTimes(3); + + getFocusedWindow.mockReturnValue({} as Electron.BrowserWindow); + getFocusedWebContents.mockReset(); + getFocusedWebContents + .mockReturnValueOnce({ id: 7, isDestroyed: () => false, focus: restoreFocus } as never) + .mockReturnValueOnce({ id: 43, isDestroyed: () => false } as never); + yield* manager.automationPress("tab_input", { key: "/" }); + expect(restoreFocus).toHaveBeenCalledTimes(3); + + getFocusedWebContents.mockReset(); + getFocusedWebContents.mockReturnValueOnce(previousFocused).mockReturnValue(previousFocused); + yield* manager.automationPress("tab_input", { key: "=" }); + expect(restoreFocus).toHaveBeenCalledTimes(3); + + sendCommand.mockClear(); + getFocusedWebContents.mockReset(); + getFocusedWebContents.mockReturnValueOnce(previousFocused).mockReturnValue(focusedGuest); + interruptPress = true; + const interruptedPress = yield* Effect.exit( + manager.automationPress("tab_input", { key: "~" }), + ); + expect(Exit.isFailure(interruptedPress)).toBe(true); + if (Exit.isFailure(interruptedPress)) { + expect(Option.getOrThrow(Cause.findErrorOption(interruptedPress.cause))).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + operation: "press", + tabId: "tab_input", + webContentsId: 42, + }); + } + expect(restoreFocus).toHaveBeenCalledTimes(3); + + interruptPress = false; + blockCleanup = true; + sendCommand.mockClear(); + getFocusedWebContents.mockReset(); + getFocusedWebContents.mockReturnValueOnce(previousFocused).mockReturnValue(focusedGuest); + const cleanupPress = yield* manager + .automationPress("tab_input", { key: "#" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(cleanupStarted); + const interruptCleanup = yield* Effect.forkChild(Fiber.interrupt(cleanupPress)); + yield* Effect.yieldNow; + releaseCleanup(); + yield* Fiber.join(interruptCleanup); + const cleanupExit = yield* Fiber.await(cleanupPress); + expect(Exit.isFailure(cleanupExit)).toBe(true); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", { + type: "keyUp", + key: "#", + code: "Digit3", + modifiers: 0, + windowsVirtualKeyCode: 51, + location: 0, + isKeypad: false, + }); + expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { + enabled: false, + }); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 324b92034f36..caeb3e18268c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -3874,6 +3874,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id }, () => webContents.getFocusedWebContents(), ); + let interruptedByHumanInput = false; let keyDownAttempted = false; const releaseInput = Effect.gen(function* () { if (keyDownAttempted) { @@ -3882,7 +3883,26 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe( Effect.ignore, ); - if (previouslyFocused && previouslyFocused.id !== wc.id && !previouslyFocused.isDestroyed()) { + const focusedWebContents = yield* attempt( + { + operation: "automationPress.getCurrentFocusedWebContents", + tabId, + webContentsId: wc.id, + }, + () => webContents.getFocusedWebContents(), + ).pipe(Effect.orElseSucceed(() => null)); + const focusedWindow = yield* attempt( + { operation: "automationPress.getFocusedWindow", tabId, webContentsId: wc.id }, + () => BrowserWindow.getFocusedWindow(), + ).pipe(Effect.orElseSucceed(() => null)); + if ( + !interruptedByHumanInput && + focusedWindow !== null && + focusedWebContents?.id === wc.id && + previouslyFocused && + previouslyFocused.id !== wc.id && + !previouslyFocused.isDestroyed() + ) { yield* attempt( { operation: "automationPress.restoreFocusedWebContents", @@ -3908,7 +3928,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* expectAgentInput(tabId, keySequence.signal); keyDownAttempted = true; yield* send("Input.dispatchKeyEvent", keySequence.keyDown); - }).pipe(Effect.ensuring(releaseInput)); + }).pipe( + Effect.onExit((exit) => + Effect.sync(() => { + interruptedByHumanInput = + Exit.isFailure(exit) && + isPreviewAutomationControlInterruptedError( + Option.getOrNull(Cause.findErrorOption(exit.cause)), + ); + }), + ), + Effect.ensuring(releaseInput), + ); }); const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( From 5f762cbc03df97a46c01b130ea69f44af905cb3e Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Fri, 4 Sep 2026 20:36:03 -0400 Subject: [PATCH 2/2] fix(desktop): guard focus after cleanup interruption --- apps/desktop/src/preview/Manager.test.ts | 2 ++ apps/desktop/src/preview/Manager.ts | 18 ++++-------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index dac4c047dba2..f331eedfb352 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -3602,6 +3602,7 @@ describe("PreviewManager", () => { ); } if (blockCleanup && method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp") { + humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); Deferred.doneUnsafe(cleanupStarted, Effect.void); await cleanupRelease; } @@ -3822,6 +3823,7 @@ describe("PreviewManager", () => { expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { enabled: false, }); + expect(restoreFocus).toHaveBeenCalledTimes(3); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index caeb3e18268c..7966ec5dfdbb 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -3866,6 +3866,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function send: SendCommand, sendCleanup: SendCommand, ) { + const initialControlEpoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; yield* prepareAutomationInput(send, false); const keySequence = makePreviewAutomationKeySequence(input, { isMac: hostPlatform === "darwin", @@ -3874,7 +3875,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id }, () => webContents.getFocusedWebContents(), ); - let interruptedByHumanInput = false; let keyDownAttempted = false; const releaseInput = Effect.gen(function* () { if (keyDownAttempted) { @@ -3895,8 +3895,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { operation: "automationPress.getFocusedWindow", tabId, webContentsId: wc.id }, () => BrowserWindow.getFocusedWindow(), ).pipe(Effect.orElseSucceed(() => null)); + const currentControlEpoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if ( - !interruptedByHumanInput && + currentControlEpoch === initialControlEpoch && focusedWindow !== null && focusedWebContents?.id === wc.id && previouslyFocused && @@ -3928,18 +3929,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* expectAgentInput(tabId, keySequence.signal); keyDownAttempted = true; yield* send("Input.dispatchKeyEvent", keySequence.keyDown); - }).pipe( - Effect.onExit((exit) => - Effect.sync(() => { - interruptedByHumanInput = - Exit.isFailure(exit) && - isPreviewAutomationControlInterruptedError( - Option.getOrNull(Cause.findErrorOption(exit.cause)), - ); - }), - ), - Effect.ensuring(releaseInput), - ); + }).pipe(Effect.ensuring(releaseInput)); }); const automationPress = Effect.fn("PreviewManager.automationPress")(function* (