Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,15 @@ const {
createFromPath,
fromId,
getFocusedWebContents,
getFocusedWindow,
mkdir,
showItemInFolder,
webviewSend,
writeFile,
writeClipboard,
} = vi.hoisted(() => ({
browserWindowConstructor: vi.fn(),
getFocusedWindow: vi.fn<() => Electron.BrowserWindow | null>(() => null),
clipboardItemConstructor: vi.fn(),
createFromPath: vi.fn((): { readonly isEmpty: () => boolean; readonly toPNG: () => Buffer } => ({
isEmpty: () => false,
Expand All @@ -203,7 +205,7 @@ const {
}));

vi.mock("electron", () => ({
BrowserWindow: browserWindowConstructor,
BrowserWindow: Object.assign(browserWindowConstructor, { getFocusedWindow }),
ClipboardItem: class {
constructor(data: Record<string, unknown>) {
clipboardItemConstructor(data);
Expand Down Expand Up @@ -542,6 +544,8 @@ describe("PreviewManager", () => {
fromId.mockClear();
getFocusedWebContents.mockReset();
getFocusedWebContents.mockReturnValue(null);
getFocusedWindow.mockReset();
getFocusedWindow.mockReturnValue({} as Electron.BrowserWindow);
mkdir.mockClear();
writeFile.mockClear();
showItemInFolder.mockClear();
Expand Down Expand Up @@ -3921,6 +3925,107 @@ describe("PreviewManager", () => {
),
);

effectIt.effect(
"hands keyboard focus back to the previous renderer after an automation click",
() =>
withManager((manager) =>
Effect.gen(function* () {
let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === "Runtime.evaluate") {
return { result: { value: { width: 800, height: 600 } } };
}
if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {
humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });
}
return undefined;
});
const restoreFocus = vi.fn();
getFocusedWebContents.mockReturnValue({
id: 7,
isDestroyed: () => false,
focus: restoreFocus,
} as never);
fromId.mockReturnValue({
id: 42,
isDestroyed: () => false,
getType: () => "webview",
getURL: () => "https://example.com",
getTitle: () => "Example",
isLoading: () => false,
isDevToolsOpened: () => false,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
on: vi.fn(),
off: vi.fn(),
ipc: {
on: vi.fn((channel: string, listener: typeof humanInput) => {
if (channel === "preview:human-input") humanInput = listener;
}),
off: vi.fn(),
},
send: webviewSend,
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setIgnoreMenuShortcuts: vi.fn(),
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => false,
attach: vi.fn(),
sendCommand,
on: vi.fn(),
off: vi.fn(),
},
} as never);

yield* manager.createTab("tab_1");
yield* manager.registerWebview("tab_1", 42);
const click = yield* manager
.automationClick("tab_1", { x: 120, y: 80 })
.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(200);
yield* Fiber.join(click);

expect(restoreFocus).toHaveBeenCalledTimes(1);
expect(restoreFocus.mock.invocationCallOrder[0]).toBeGreaterThan(
sendCommand.mock.invocationCallOrder.at(-1) ?? 0,
);

const offscreen = yield* manager
.automationClick("tab_1", { x: 5000, y: 80 })
.pipe(Effect.exit, Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(200);
expect((yield* Fiber.join(offscreen))._tag).toBe("Failure");
expect(restoreFocus).toHaveBeenCalledTimes(2);

// Focus that moved to a third renderer while the click ran is left alone.
getFocusedWebContents
.mockReturnValueOnce({ id: 7, isDestroyed: () => false, focus: restoreFocus } as never)
.mockReturnValue({ id: 9, isDestroyed: () => false, focus: vi.fn() } as never);
const moved = yield* manager
.automationClick("tab_1", { x: 120, y: 80 })
.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(200);
yield* Fiber.join(moved);
expect(restoreFocus).toHaveBeenCalledTimes(2);

// The user switched to another app while the click ran: T3 has no focused
// window and no focused renderer, so nothing pulls them back.
getFocusedWebContents
.mockReturnValueOnce({ id: 7, isDestroyed: () => false, focus: restoreFocus } as never)
.mockReturnValue(null);
getFocusedWindow.mockReturnValue(null);
const left = yield* manager
.automationClick("tab_1", { x: 120, y: 80 })
.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(200);
yield* Fiber.join(left);
expect(restoreFocus).toHaveBeenCalledTimes(2);
}),
),
);

effectIt.effect("types in background webviews and enables native key input", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
70 changes: 59 additions & 11 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3719,7 +3719,59 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});

// Dispatching input moves keyboard focus into the guest renderer as a side
// effect. Hand it back to whatever had it before, so the user's next keystroke
// does not land in the previewed page, which may not even be visible.
const restoreFocusedWebContents = Effect.fn("PreviewManager.restoreFocusedWebContents")(
function* (
operation: string,
tabId: string,
wc: Electron.WebContents,
previouslyFocused: Electron.WebContents | null,
) {
if (!previouslyFocused || previouslyFocused.id === wc.id || previouslyFocused.isDestroyed()) {
return;
}
// A newer selection the user made while the action ran wins over the restore.
const focusedNow = yield* attempt({ operation, tabId, webContentsId: wc.id }, () =>
webContents.getFocusedWebContents(),
).pipe(Effect.orElseSucceed(() => null));
if (focusedNow && focusedNow.id !== wc.id && focusedNow.id !== previouslyFocused.id) {
return;
}
// The user left T3 for another app while the action ran; do not pull them back.
if (focusedNow === null && BrowserWindow.getFocusedWindow() === null) {
return;
}
yield* attempt({ operation, tabId, webContentsId: previouslyFocused.id }, () =>
previouslyFocused.focus(),
).pipe(Effect.ignore);
Comment thread
Mnigos marked this conversation as resolved.
},
);

const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* (
tabId: string,
wc: Electron.WebContents,
input: PreviewAutomationClickInput,
send: SendCommand,
) {
const previouslyFocused = yield* attempt(
{ operation: "automationClick.getFocusedWebContents", tabId, webContentsId: wc.id },
() => webContents.getFocusedWebContents(),
);
yield* dispatchAutomationClick(tabId, input, send).pipe(
Effect.ensuring(
restoreFocusedWebContents(
"automationClick.restoreFocusedWebContents",
tabId,
wc,
previouslyFocused,
),
),
);
});

const dispatchAutomationClick = Effect.fn("PreviewManager.dispatchAutomationClick")(function* (
tabId: string,
input: PreviewAutomationClickInput,
send: SendCommand,
Expand Down Expand Up @@ -3782,7 +3834,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
) {
const wc = yield* requireWebContents(tabId);
yield* withControlSession(tabId, wc, "click", (send) =>
performAutomationClick(tabId, input, send),
performAutomationClick(tabId, wc, input, send),
);
});

Expand Down Expand Up @@ -3935,16 +3987,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe(
Effect.ignore,
);
if (previouslyFocused && previouslyFocused.id !== wc.id && !previouslyFocused.isDestroyed()) {
yield* attempt(
{
operation: "automationPress.restoreFocusedWebContents",
tabId,
webContentsId: previouslyFocused.id,
},
() => previouslyFocused.focus(),
).pipe(Effect.ignore);
}
yield* restoreFocusedWebContents(
"automationPress.restoreFocusedWebContents",
tabId,
wc,
previouslyFocused,
);
});

// Focus the guest WebContents itself, not its containing BrowserWindow. This
Expand Down
Loading