Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"electron": "44.1.0",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"ffi-rs": "1.3.2",
"get-windows": "9.3.0",
"playwright-core": "1.60.0",
"react-grab": "^0.1.32",
Expand Down
84 changes: 82 additions & 2 deletions apps/desktop/src/electron/ElectronWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { beforeEach, vi } from "vite-plus/test";

const {
activeWindowMock,
activateWindowsForegroundMock,
appFocusMock,
browserWindowMock,
getAllWindowsMock,
Expand All @@ -17,6 +18,7 @@ const {
nativeAppListMock,
} = vi.hoisted(() => ({
activeWindowMock: vi.fn(),
activateWindowsForegroundMock: vi.fn(),
appFocusMock: vi.fn(),
browserWindowMock: vi.fn(function BrowserWindowMock() {}),
getAllWindowsMock: vi.fn(),
Expand All @@ -27,6 +29,10 @@ const {

vi.mock("get-windows", () => ({ activeWindow: activeWindowMock }));

vi.mock("./WindowsForeground.ts", () => ({
activateWindowsForeground: activateWindowsForegroundMock,
}));

vi.mock("@crowecawcaw/xa11y", () => ({
App: {
byPid: nativeAppByPidMock,
Expand Down Expand Up @@ -78,6 +84,7 @@ function makeWindowsRevealWindow() {
describe("ElectronWindow", () => {
beforeEach(() => {
activeWindowMock.mockReset().mockResolvedValue(undefined);
activateWindowsForegroundMock.mockReset().mockResolvedValue(undefined);
appFocusMock.mockReset();
browserWindowMock.mockReset();
getAllWindowsMock.mockReset();
Expand Down Expand Up @@ -219,7 +226,30 @@ describe("ElectronWindow", () => {
}).pipe(Effect.provide(TestLayer)),
);

it.effect("awaits native foreground focus even when Electron reports the window focused", () =>
it.effect("uses native Windows activation without starting the accessibility fallback", () =>
Effect.gen(function* () {
const operations: Array<string> = [];
appFocusMock.mockImplementation(() => operations.push("app-focus"));
activateWindowsForegroundMock.mockImplementation(async () => {
operations.push("native-activation");
});
const window = {
...makeWindowsRevealWindow(),
show: vi.fn(() => operations.push("show")),
moveTop: vi.fn(() => operations.push("move-top")),
focus: vi.fn(() => operations.push("focus")),
} as unknown as Electron.BrowserWindow;
const electronWindow = yield* ElectronWindow.ElectronWindow;

yield* electronWindow.reveal(window);

assert.deepEqual(operations, ["app-focus", "show", "move-top", "focus", "native-activation"]);
assert.lengthOf(activeWindowMock.mock.calls, 0);
assert.lengthOf(nativeAppListMock.mock.calls, 0);
}).pipe(Effect.provide(testLayer("win32"))),
);

it.effect("falls back to accessibility before retrying native Windows activation", () =>
Effect.gen(function* () {
const operations: Array<string> = [];
const nativeFocusStarted = Promise.withResolvers<void>();
Expand All @@ -233,6 +263,11 @@ describe("ElectronWindow", () => {
}>
>();
appFocusMock.mockImplementation(() => operations.push("app-focus"));
activateWindowsForegroundMock
.mockRejectedValueOnce(new Error("Windows initially refused foreground activation"))
.mockImplementationOnce(async () => {
operations.push("native-activation");
});
nativeAppListMock.mockImplementation(() => {
listingStarted.resolve();
return listedApps.promise;
Expand Down Expand Up @@ -307,8 +342,10 @@ describe("ElectronWindow", () => {
"move-top",
"focus",
"native-focus",
"native-activation",
"revealed",
]);
assert.lengthOf(activateWindowsForegroundMock.mock.calls, 2);
assert.lengthOf(nativeAppByPidMock.mock.calls, 0);
assert.lengthOf(readNativeBounds.mock.calls, 1);
}).pipe(Effect.provide(testLayer("win32"))),
Expand All @@ -319,6 +356,9 @@ describe("ElectronWindow", () => {
(handleBytes) =>
Effect.gen(function* () {
const window = makeWindowsRevealWindow();
activateWindowsForegroundMock.mockRejectedValueOnce(
new Error("Windows initially refused foreground activation"),
);
const hwnd = handleBytes === 4 ? 0xf123_4567 : 0x1_f123_4567;
const handle = Buffer.alloc(handleBytes);
if (handleBytes === 4) handle.writeUInt32LE(hwnd);
Expand All @@ -341,6 +381,9 @@ describe("ElectronWindow", () => {
Effect.gen(function* () {
const window = makeWindowsRevealWindow();
const focus = vi.fn(async () => undefined);
activateWindowsForegroundMock.mockRejectedValueOnce(
new Error("Windows initially refused foreground activation"),
);
activeWindowMock.mockResolvedValue({
id: foreground.id,
owner: { processId: foreground.processId },
Expand All @@ -363,6 +406,9 @@ describe("ElectronWindow", () => {
Effect.gen(function* () {
const window = makeWindowsRevealWindow();
const focus = vi.fn(async () => undefined);
activateWindowsForegroundMock.mockRejectedValueOnce(
new Error("Windows initially refused foreground activation"),
);
activeWindowMock.mockRejectedValue(new Error("Foreground query unavailable"));
nativeAppListMock.mockResolvedValue([
{
Expand All @@ -384,6 +430,9 @@ describe("ElectronWindow", () => {
const focus = vi.fn(async () => {
throw new Error("Focus rejected");
});
activateWindowsForegroundMock.mockRejectedValueOnce(
new Error("Windows initially refused foreground activation"),
);
nativeAppListMock.mockResolvedValue([
{
pid: process.pid,
Expand All @@ -398,9 +447,37 @@ describe("ElectronWindow", () => {
}).pipe(Effect.provide(testLayer("win32"))),
);

it.effect("fails reveal when Windows refuses foreground activation", () =>
Effect.gen(function* () {
const cause = new Error("Windows refused foreground activation");
const window = makeWindowsRevealWindow();
activateWindowsForegroundMock.mockRejectedValue(cause);
const electronWindow = yield* ElectronWindow.ElectronWindow;

const exit = yield* Effect.exit(
electronWindow.reveal(window as unknown as Electron.BrowserWindow),
);

assert.equal(exit._tag, "Failure");
if (exit._tag === "Failure") {
const error = Cause.squash(exit.cause);
assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError);
assert.equal(error.operation, "reveal-window");
assert.strictEqual(error.cause, cause);
}
assert.deepEqual(activateWindowsForegroundMock.mock.calls, [
[window.getNativeWindowHandle.mock.results[0]?.value],
[window.getNativeWindowHandle.mock.results[1]?.value],
]);
}).pipe(Effect.provide(testLayer("win32"))),
);

it.effect("cancels native focus when destroyed during the foreground query", () =>
Effect.gen(function* () {
const window = makeWindowsRevealWindow();
activateWindowsForegroundMock.mockRejectedValueOnce(
new Error("Windows initially refused foreground activation"),
);
const queryStarted = Promise.withResolvers<void>();
const foreground = Promise.withResolvers<undefined>();
activeWindowMock.mockImplementation(() => {
Expand All @@ -418,7 +495,7 @@ describe("ElectronWindow", () => {
yield* Fiber.join(revealFiber);

assert.lengthOf(nativeAppListMock.mock.calls, 0);
assert.lengthOf(window.getNativeWindowHandle.mock.calls, 0);
assert.lengthOf(window.getNativeWindowHandle.mock.calls, 1);
assert.lengthOf(window.getTitle.mock.calls, 0);
}).pipe(Effect.provide(testLayer("win32"))),
);
Expand All @@ -436,6 +513,9 @@ describe("ElectronWindow", () => {
return listedApps.promise;
});
const window = makeWindowsRevealWindow();
activateWindowsForegroundMock.mockRejectedValueOnce(
new Error("Windows initially refused foreground activation"),
);
const electronWindow = yield* ElectronWindow.ElectronWindow;

const revealFiber = yield* electronWindow
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/electron/ElectronWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import * as Schema from "effect/Schema";
import * as Electron from "electron";
import { activeWindow } from "get-windows";

import { activateWindowsForeground } from "./WindowsForeground.ts";

async function isWindowsBrowserWindowForeground(window: Electron.BrowserWindow): Promise<boolean> {
const foreground = await activeWindow().catch(() => undefined);
if (window.isDestroyed() || foreground?.owner.processId !== process.pid) return false;
Expand Down Expand Up @@ -278,7 +280,14 @@ export const make = Effect.gen(function* () {
window.focus();

if (platform === "win32") {
await focusWindowsBrowserWindow(window).catch(() => undefined);
try {
await activateWindowsForeground(window.getNativeWindowHandle());
} catch {
await focusWindowsBrowserWindow(window).catch(() => undefined);
if (!window.isDestroyed()) {
await activateWindowsForeground(window.getNativeWindowHandle());
}
}
}
},
catch: (cause) =>
Expand Down
93 changes: 93 additions & 0 deletions apps/desktop/src/electron/WindowsForeground.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { assert, describe, it } from "@effect/vitest";
import { vi } from "vite-plus/test";

import {
activateWindowsForegroundWithApi,
type WindowsForegroundApi,
} from "./WindowsForeground.ts";

function nativeHandle(value: bigint, bytes = 8): Buffer {
const handle = Buffer.alloc(bytes);
if (bytes === 8) handle.writeBigUInt64LE(value);
else handle.writeUInt32LE(Number(value));
return handle;
}

function makeApi(input: {
readonly foregroundWindow?: bigint;
readonly currentThreadId?: number;
readonly foregroundThreadId?: number;
readonly attached?: boolean;
readonly activated?: boolean;
}) {
return {
getCurrentThreadId: vi.fn(() => input.currentThreadId ?? 10),
getForegroundWindow: vi.fn(() => input.foregroundWindow ?? 99n),
getWindowThreadId: vi.fn(() => input.foregroundThreadId ?? 20),
attachThreadInput: vi.fn((_source, _target, attach) =>
attach ? (input.attached ?? true) : true,
),
setForegroundWindow: vi.fn(() => input.activated ?? true),
} satisfies WindowsForegroundApi;
}

describe("Windows foreground activation", () => {
it.each([4, 8])("activates a %i-byte native window handle", (bytes) => {
const api = makeApi({});
const handle = nativeHandle(41n, bytes);

assert.isTrue(activateWindowsForegroundWithApi(handle, api));

assert.deepEqual(api.attachThreadInput.mock.calls, [
[10, 20, true],
[10, 20, false],
]);
assert.deepEqual(api.setForegroundWindow.mock.calls, [[41n]]);
});

it("does not disturb input queues when T3 is already foreground", () => {
const api = makeApi({ foregroundWindow: 41n });

assert.isTrue(activateWindowsForegroundWithApi(nativeHandle(41n), api));

assert.lengthOf(api.getCurrentThreadId.mock.calls, 0);
assert.lengthOf(api.attachThreadInput.mock.calls, 0);
assert.lengthOf(api.setForegroundWindow.mock.calls, 0);
});

it("does not attach a thread to itself", () => {
const api = makeApi({ currentThreadId: 20, foregroundThreadId: 20 });

assert.isTrue(activateWindowsForegroundWithApi(nativeHandle(41n), api));

assert.lengthOf(api.attachThreadInput.mock.calls, 0);
assert.deepEqual(api.setForegroundWindow.mock.calls, [[41n]]);
});

it("uses SetForegroundWindow's result as the activation receipt", () => {
const api = makeApi({ activated: false });

assert.isFalse(activateWindowsForegroundWithApi(nativeHandle(41n), api));

assert.deepEqual(api.attachThreadInput.mock.calls, [
[10, 20, true],
[10, 20, false],
]);
});

it("still asks Windows directly when the input queues cannot be attached", () => {
const api = makeApi({ attached: false });

assert.isTrue(activateWindowsForegroundWithApi(nativeHandle(41n), api));

assert.deepEqual(api.attachThreadInput.mock.calls, [[10, 20, true]]);
assert.deepEqual(api.setForegroundWindow.mock.calls, [[41n]]);
});

it("rejects malformed native handles", () => {
const api = makeApi({});

assert.throws(() => activateWindowsForegroundWithApi(Buffer.alloc(6), api));
assert.lengthOf(api.getForegroundWindow.mock.calls, 0);
});
});
Loading
Loading