diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 78d54a66aed7..f5baffed771d 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -23,12 +23,17 @@ if (!Number.isInteger(port) || port <= 0) { const requiredFiles = [ "dist-electron/main.cjs", + "dist-electron/electron/WindowsForegroundFocusWorker.cjs", "dist-electron/preload.cjs", "dist-electron/windowCapture/GlobalShiftShortcutWorker.cjs", "../server/dist/bin.mjs", ]; const watchedDirectories = [ { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) }, + { + directory: "dist-electron/electron", + files: new Set(["WindowsForegroundFocusWorker.cjs"]), + }, { directory: "dist-electron/windowCapture", files: new Set(["GlobalShiftShortcutWorker.cjs"]), diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 4e22e11506a5..28d9a815f480 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -69,6 +69,7 @@ function makeElectronWindowLayer(destroyAll: Effect.Effect = Effect.void) focusedMainOrFirst: Effect.die("unexpected focused window read"), setMain: () => Effect.void, clearMain: () => Effect.void, + prepareReveal: () => Effect.succeed(false), reveal: () => Effect.void, sendAll: () => Effect.void, destroyAll, @@ -92,6 +93,7 @@ function makeDesktopWindowLayer( handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void, + prepareCaptureReveal: Effect.void, dispatchMenuAction: () => Effect.void, dispatchWindowCaptureReady: () => Effect.void, zoomMain: () => Effect.void, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index d0293b39bb76..665a06070c11 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -96,6 +96,7 @@ function makePoolLayer( handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, + prepareCaptureReveal: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), dispatchWindowCaptureReady: () => Effect.void, zoomMain: () => Effect.die("unexpected zoom"), diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 9d5451eb9669..f23fcb776d4a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -16,6 +16,9 @@ const { getFocusedWindowMock, nativeAppByPidMock, nativeAppListMock, + shellHostedForegroundMock, + windowsForegroundFocusMock, + windowsForegroundPrepareMock, } = vi.hoisted(() => ({ activeWindowMock: vi.fn(), activateWindowsForegroundMock: vi.fn(), @@ -25,12 +28,24 @@ const { getFocusedWindowMock: vi.fn(), nativeAppByPidMock: vi.fn(), nativeAppListMock: vi.fn(), + shellHostedForegroundMock: vi.fn(), + windowsForegroundFocusMock: vi.fn(), + windowsForegroundPrepareMock: vi.fn(), })); vi.mock("get-windows", () => ({ activeWindow: activeWindowMock })); vi.mock("./WindowsForeground.ts", () => ({ activateWindowsForeground: activateWindowsForegroundMock, + isWindowsShellHostedForeground: shellHostedForegroundMock, +})); + +vi.mock("./WindowsForegroundFocusThread.ts", () => ({ + startWindowsForegroundFocusThread: () => ({ + prepare: windowsForegroundPrepareMock, + focus: windowsForegroundFocusMock, + close: () => undefined, + }), })); vi.mock("@crowecawcaw/xa11y", () => ({ @@ -76,6 +91,7 @@ function makeWindowsRevealWindow() { focus: vi.fn(), getTitle: vi.fn(() => "T3 Code (Dev)"), getBounds: vi.fn(() => ({ x: 100, y: 50, width: 1_200, height: 800 })), + getContentBounds: vi.fn(() => ({ x: 108, y: 50, width: 1_184, height: 792 })), getNativeWindowHandle: vi.fn(() => Buffer.from([41, 0, 0, 0])), restore: vi.fn(), }; @@ -91,6 +107,9 @@ describe("ElectronWindow", () => { getFocusedWindowMock.mockReset(); nativeAppByPidMock.mockReset(); nativeAppListMock.mockReset().mockResolvedValue([]); + shellHostedForegroundMock.mockReset().mockResolvedValue(false); + windowsForegroundFocusMock.mockReset().mockResolvedValue(false); + windowsForegroundPrepareMock.mockReset().mockResolvedValue(false); }); it.effect("preserves schema-safe creation context and the Electron cause", () => @@ -351,6 +370,78 @@ describe("ElectronWindow", () => { }).pipe(Effect.provide(testLayer("win32"))), ); + it.effect("focuses the exact T3 window before activating from a shell-hosted app", () => + Effect.gen(function* () { + const operations: Array = []; + shellHostedForegroundMock.mockResolvedValue(true); + windowsForegroundFocusMock.mockImplementation(async () => { + operations.push("native-focus"); + return true; + }); + 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; + appFocusMock.mockImplementation(() => operations.push("app-focus")); + const electronWindow = yield* ElectronWindow.ElectronWindow; + + yield* electronWindow.reveal(window); + + assert.deepEqual(operations, [ + "app-focus", + "show", + "move-top", + "focus", + "native-focus", + "native-activation", + ]); + assert.lengthOf(activateWindowsForegroundMock.mock.calls, 1); + assert.deepEqual(windowsForegroundFocusMock.mock.calls, [ + [ + { + windowId: 41, + processId: process.pid, + title: "T3 Code (Dev)", + bounds: { x: 100, y: 50, width: 1_200, height: 800 }, + contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, + }, + ], + ]); + }).pipe(Effect.provide(testLayer("win32"))), + ); + + it.effect("prepares the exact T3 window before a capture overlay", () => + Effect.gen(function* () { + windowsForegroundPrepareMock.mockResolvedValue(true); + const window = makeWindowsRevealWindow(); + const electronWindow = yield* ElectronWindow.ElectronWindow; + + const prepared = yield* electronWindow.prepareReveal( + window as unknown as Electron.BrowserWindow, + ); + + assert.isTrue(prepared); + assert.deepEqual(windowsForegroundPrepareMock.mock.calls, [ + [ + { + windowId: 41, + processId: process.pid, + title: "T3 Code (Dev)", + bounds: { x: 100, y: 50, width: 1_200, height: 800 }, + contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, + }, + ], + ]); + assert.lengthOf(windowsForegroundFocusMock.mock.calls, 0); + assert.lengthOf(activateWindowsForegroundMock.mock.calls, 0); + }).pipe(Effect.provide(testLayer("win32"))), + ); + it.effect.each([4, 8])( "skips native focus only when the foreground matches the %i-byte HWND and process", (handleBytes) => @@ -529,7 +620,6 @@ describe("ElectronWindow", () => { yield* Fiber.join(revealFiber); assert.lengthOf(asElement.mock.calls, 0); - assert.lengthOf(window.getTitle.mock.calls, 0); }).pipe(Effect.provide(testLayer("win32"))), ); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index d4cd8b63375c..c9d6c2fef3cf 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off -- This desktop-only service resolves its bundled helper beside the Electron entrypoint. + +import * as NodePath from "node:path"; + import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import type * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -11,7 +15,18 @@ import * as Schema from "effect/Schema"; import * as Electron from "electron"; import { activeWindow } from "get-windows"; -import { activateWindowsForeground } from "./WindowsForeground.ts"; +import { activateWindowsForeground, isWindowsShellHostedForeground } from "./WindowsForeground.ts"; +import { startWindowsForegroundFocusThread } from "./WindowsForegroundFocusThread.ts"; + +function windowsForegroundFocusTarget(window: Electron.BrowserWindow) { + return { + windowId: window.id, + processId: process.pid, + title: window.getTitle(), + bounds: window.getBounds(), + contentBounds: window.getContentBounds(), + }; +} async function isWindowsBrowserWindowForeground(window: Electron.BrowserWindow): Promise { const foreground = await activeWindow().catch(() => undefined); @@ -129,6 +144,7 @@ export class ElectronWindow extends Context.Service< readonly focusedMainOrFirst: Effect.Effect>; readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; readonly clearMain: (window: Option.Option) => Effect.Effect; + readonly prepareReveal: (window: Electron.BrowserWindow) => Effect.Effect; readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; readonly destroyAll: Effect.Effect; @@ -140,6 +156,12 @@ export class ElectronWindow extends Context.Service< export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; + const windowsForegroundFocus = + platform === "win32" + ? startWindowsForegroundFocusThread( + NodePath.join(__dirname, "electron", "WindowsForegroundFocusWorker.cjs"), + ) + : undefined; const mainWindowRef = yield* Ref.make>(Option.none()); const listWindows = Effect.try({ @@ -250,6 +272,15 @@ export const make = Effect.gen(function* () { } return Option.none(); }), + prepareReveal: (window) => + Effect.promise(async () => { + if (platform !== "win32" || !windowsForegroundFocus || window.isDestroyed()) { + return false; + } + return windowsForegroundFocus + .prepare(windowsForegroundFocusTarget(window)) + .catch(() => false); + }), reveal: (window) => Effect.tryPromise({ try: async () => { @@ -257,6 +288,9 @@ export const make = Effect.gen(function* () { return; } + const shellHostedForeground = + platform === "win32" && (await isWindowsShellHostedForeground().catch(() => false)); + if (window.isMinimized()) { window.restore(); } @@ -280,10 +314,24 @@ export const make = Effect.gen(function* () { window.focus(); if (platform === "win32") { + if (shellHostedForeground) { + await windowsForegroundFocus + ?.focus(windowsForegroundFocusTarget(window)) + .catch(() => false); + } try { await activateWindowsForeground(window.getNativeWindowHandle()); } catch { - await focusWindowsBrowserWindow(window).catch(() => undefined); + const needsFocus = !(await isWindowsBrowserWindowForeground(window)); + const focused = + needsFocus && !window.isDestroyed() + ? await windowsForegroundFocus + ?.focus(windowsForegroundFocusTarget(window)) + .catch(() => false) + : false; + if (needsFocus && !focused && !shellHostedForeground) { + await focusWindowsBrowserWindow(window).catch(() => undefined); + } if (!window.isDestroyed()) { await activateWindowsForeground(window.getNativeWindowHandle()); } diff --git a/apps/desktop/src/electron/WindowsForeground.test.ts b/apps/desktop/src/electron/WindowsForeground.test.ts index 522f4da65ab1..1bf6d58d2479 100644 --- a/apps/desktop/src/electron/WindowsForeground.test.ts +++ b/apps/desktop/src/electron/WindowsForeground.test.ts @@ -3,6 +3,7 @@ import { vi } from "vite-plus/test"; import { activateWindowsForegroundWithApi, + isWindowsShellHostedForegroundWithApi, type WindowsForegroundApi, } from "./WindowsForeground.ts"; @@ -23,6 +24,7 @@ function makeApi(input: { return { getCurrentThreadId: vi.fn(() => input.currentThreadId ?? 10), getForegroundWindow: vi.fn(() => input.foregroundWindow ?? 99n), + getWindowClassName: vi.fn(() => "Chrome_WidgetWin_1"), getWindowThreadId: vi.fn(() => input.foregroundThreadId ?? 20), attachThreadInput: vi.fn((_source, _target, attach) => attach ? (input.attached ?? true) : true, @@ -90,4 +92,21 @@ describe("Windows foreground activation", () => { assert.throws(() => activateWindowsForegroundWithApi(Buffer.alloc(6), api)); assert.lengthOf(api.getForegroundWindow.mock.calls, 0); }); + + it("recognizes a shell-hosted foreground window", () => { + const api = makeApi({}); + api.getWindowClassName.mockReturnValue("ApplicationFrameWindow"); + + assert.isTrue(isWindowsShellHostedForegroundWithApi(api)); + assert.deepEqual(api.getWindowClassName.mock.calls, [[99n]]); + }); + + it("does not classify ordinary or missing foreground windows as shell-hosted", () => { + const ordinary = makeApi({}); + const missing = makeApi({ foregroundWindow: 0n }); + + assert.isFalse(isWindowsShellHostedForegroundWithApi(ordinary)); + assert.isFalse(isWindowsShellHostedForegroundWithApi(missing)); + assert.lengthOf(missing.getWindowClassName.mock.calls, 0); + }); }); diff --git a/apps/desktop/src/electron/WindowsForeground.ts b/apps/desktop/src/electron/WindowsForeground.ts index 045eb30b866e..b7a41e519fb0 100644 --- a/apps/desktop/src/electron/WindowsForeground.ts +++ b/apps/desktop/src/electron/WindowsForeground.ts @@ -1,6 +1,7 @@ export interface WindowsForegroundApi { readonly getCurrentThreadId: () => number; readonly getForegroundWindow: () => bigint; + readonly getWindowClassName: (windowHandle: bigint) => string; readonly getWindowThreadId: (windowHandle: bigint) => number; readonly attachThreadInput: ( sourceThreadId: number, @@ -10,6 +11,8 @@ export interface WindowsForegroundApi { readonly setForegroundWindow: (windowHandle: bigint) => boolean; } +const WINDOWS_SHELL_HOSTED_WINDOW_CLASSES = new Set(["ApplicationFrameWindow"]); + function nativeWindowHandle(buffer: Buffer): bigint { if (buffer.length === 8) return buffer.readBigUInt64LE(); if (buffer.length === 4) return BigInt(buffer.readUInt32LE()); @@ -39,6 +42,14 @@ export function activateWindowsForegroundWithApi( } } +export function isWindowsShellHostedForegroundWithApi(api: WindowsForegroundApi): boolean { + const foregroundWindow = api.getForegroundWindow(); + return ( + foregroundWindow !== 0n && + WINDOWS_SHELL_HOSTED_WINDOW_CLASSES.has(api.getWindowClassName(foregroundWindow)) + ); +} + let windowsForegroundApiPromise: Promise | undefined; function loadWindowsForegroundApi(): Promise { @@ -65,6 +76,17 @@ function loadWindowsForegroundApi(): Promise { paramsType: [], paramsValue: [], }) as bigint, + getWindowClassName: (windowHandle) => { + const buffer = Buffer.alloc(512); + const length = load({ + library: user32, + funcName: "GetClassNameW", + retType: DataType.I32, + paramsType: [DataType.BigInt, DataType.U8Array, DataType.I32], + paramsValue: [windowHandle, buffer, buffer.byteLength / 2], + }); + return length > 0 ? buffer.subarray(0, length * 2).toString("utf16le") : ""; + }, getWindowThreadId: (windowHandle) => load({ library: user32, @@ -99,3 +121,7 @@ export async function activateWindowsForeground(handleBuffer: Buffer): Promise { + return isWindowsShellHostedForegroundWithApi(await loadWindowsForegroundApi()); +} diff --git a/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts new file mode 100644 index 000000000000..46193ca2c341 --- /dev/null +++ b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts @@ -0,0 +1,100 @@ +import { assert, beforeEach, it } from "@effect/vitest"; +import * as NodeEvents from "node:events"; +import { vi } from "vite-plus/test"; + +const workerConstructorMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:worker_threads", () => ({ + Worker: function Worker(...args: ReadonlyArray) { + return workerConstructorMock(...args); + }, +})); + +import { startWindowsForegroundFocusThread } from "./WindowsForegroundFocusThread.ts"; + +const makeWorker = () => + Object.assign(new NodeEvents.EventEmitter(), { + postMessage: vi.fn(), + terminate: vi.fn(async () => 0), + unref: vi.fn(), + }); + +let worker = makeWorker(); +const target = { + windowId: 7, + processId: 42, + title: "T3 Code (Dev)", + bounds: { x: 100, y: 50, width: 1_200, height: 800 }, + contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, +}; + +beforeEach(() => { + worker = makeWorker(); + workerConstructorMock.mockReset().mockReturnValue(worker); +}); + +it("queues focus until the helper is ready", async () => { + const thread = startWindowsForegroundFocusThread("focus.cjs"); + const focused = thread.focus(target); + + assert.lengthOf(worker.postMessage.mock.calls, 0); + worker.emit("message", "ready"); + assert.deepEqual(worker.postMessage.mock.calls[0]?.[0], { + type: "focus", + requestId: 1, + target, + }); + worker.emit("message", { type: "result", requestId: 1, focused: true }); + + assert.isTrue(await focused); + assert.deepEqual(workerConstructorMock.mock.calls, [["focus.cjs"]]); + thread.close(); +}); + +it("fails pending focus when the helper exits", async () => { + const replacement = makeWorker(); + workerConstructorMock.mockReturnValueOnce(worker).mockReturnValueOnce(replacement); + const thread = startWindowsForegroundFocusThread("focus.cjs"); + const focused = thread.focus(target); + + worker.emit("exit", 1); + + assert.isFalse(await focused); + assert.lengthOf(workerConstructorMock.mock.calls, 1); + const prepared = thread.prepare(target); + replacement.emit("message", "ready"); + replacement.emit("message", { type: "result", requestId: 2, focused: true }); + assert.isTrue(await prepared); + assert.lengthOf(workerConstructorMock.mock.calls, 2); + thread.close(); +}); + +it("replaces a timed-out helper before accepting more work", async () => { + vi.useFakeTimers(); + const replacement = makeWorker(); + workerConstructorMock.mockReturnValueOnce(worker).mockReturnValueOnce(replacement); + const thread = startWindowsForegroundFocusThread("focus.cjs"); + + try { + worker.emit("message", "ready"); + const focused = thread.focus(target); + await vi.advanceTimersByTimeAsync(1_000); + + assert.isFalse(await focused); + assert.lengthOf(worker.terminate.mock.calls, 1); + assert.lengthOf(workerConstructorMock.mock.calls, 2); + + replacement.emit("message", "ready"); + const prepared = thread.prepare(target); + assert.deepEqual(replacement.postMessage.mock.calls[0]?.[0], { + type: "prepare", + requestId: 2, + target, + }); + replacement.emit("message", { type: "result", requestId: 2, focused: true }); + assert.isTrue(await prepared); + } finally { + thread.close(); + vi.useRealTimers(); + } +}); diff --git a/apps/desktop/src/electron/WindowsForegroundFocusThread.ts b/apps/desktop/src/electron/WindowsForegroundFocusThread.ts new file mode 100644 index 000000000000..53e18aaf8c29 --- /dev/null +++ b/apps/desktop/src/electron/WindowsForegroundFocusThread.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics globalTimers:off -- The helper timeout runs at a worker callback boundary outside any Effect fiber. +// @effect-diagnostics nodeBuiltinImport:off -- This desktop-only helper owns a Node worker. + +import * as NodeWorkerThreads from "node:worker_threads"; + +import type * as Electron from "electron"; + +const FOCUS_TIMEOUT_MS = 1_000; + +export type WindowsForegroundFocusTarget = { + readonly windowId: number; + readonly processId: number; + readonly title: string; + readonly bounds: Electron.Rectangle; + readonly contentBounds: Electron.Rectangle; +}; + +export type WindowsForegroundFocusThread = { + readonly prepare: (target: WindowsForegroundFocusTarget) => Promise; + readonly focus: (target: WindowsForegroundFocusTarget) => Promise; + readonly close: () => void; +}; + +type FocusRequest = { + readonly type: "prepare" | "focus"; + readonly requestId: number; + readonly target: WindowsForegroundFocusTarget; +}; + +type FocusResult = { + readonly type: "result"; + readonly requestId: number; + readonly focused: boolean; +}; + +const unavailableThread = (): WindowsForegroundFocusThread => ({ + prepare: async () => false, + focus: async () => false, + close: () => undefined, +}); + +export function startWindowsForegroundFocusThread( + workerPath: string, +): WindowsForegroundFocusThread { + let worker: NodeWorkerThreads.Worker | undefined; + let ready = false; + let closed = false; + let nextRequestId = 1; + const pending = new Map< + number, + { + readonly request: FocusRequest; + readonly resolve: (focused: boolean) => void; + readonly timeout: ReturnType; + } + >(); + + const finish = (requestId: number, focused: boolean) => { + const request = pending.get(requestId); + if (!request) return; + pending.delete(requestId); + clearTimeout(request.timeout); + request.resolve(focused); + }; + const send = (request: FocusRequest, targetWorker: NodeWorkerThreads.Worker) => { + if (!ready || closed || worker !== targetWorker) return; + try { + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- Node workers do not accept a target origin. + targetWorker.postMessage(request); + } catch { + finish(request.requestId, false); + } + }; + const start = (): boolean => { + if (closed) return false; + let nextWorker: NodeWorkerThreads.Worker; + try { + nextWorker = new NodeWorkerThreads.Worker(workerPath); + nextWorker.unref(); + } catch { + return false; + } + + worker = nextWorker; + ready = false; + const reset = () => { + if (worker !== nextWorker) return; + worker = undefined; + ready = false; + for (const requestId of pending.keys()) finish(requestId, false); + }; + nextWorker.on("message", (rawMessage) => { + if (worker !== nextWorker) return; + if (rawMessage === "ready") { + ready = true; + for (const request of pending.values()) send(request.request, nextWorker); + return; + } + const message = rawMessage as FocusResult; + if (message.type === "result") finish(message.requestId, message.focused); + }); + nextWorker.once("error", reset); + nextWorker.once("exit", reset); + return true; + }; + + if (!start()) return unavailableThread(); + + const restart = (timedOutWorker: NodeWorkerThreads.Worker | undefined) => { + if (!timedOutWorker || worker !== timedOutWorker) return; + worker = undefined; + ready = false; + for (const requestId of pending.keys()) finish(requestId, false); + void timedOutWorker.terminate(); + start(); + }; + const request = (type: FocusRequest["type"], target: WindowsForegroundFocusTarget) => + new Promise((resolve) => { + if (closed || (!worker && !start())) { + resolve(false); + return; + } + const requestId = nextRequestId++; + const focusRequest = { type, requestId, target } satisfies FocusRequest; + const requestWorker = worker!; + const timeout = setTimeout(() => { + finish(requestId, false); + restart(requestWorker); + }, FOCUS_TIMEOUT_MS); + timeout.unref(); + pending.set(requestId, { request: focusRequest, resolve, timeout }); + send(focusRequest, requestWorker); + }); + + return { + prepare: (target) => request("prepare", target), + focus: (target) => request("focus", target), + close: () => { + if (closed) return; + closed = true; + const activeWorker = worker; + worker = undefined; + ready = false; + for (const requestId of pending.keys()) finish(requestId, false); + if (activeWorker) void activeWorker.terminate(); + }, + }; +} diff --git a/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts b/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts new file mode 100644 index 000000000000..fb61c25fed78 --- /dev/null +++ b/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts @@ -0,0 +1,98 @@ +import * as NodeWorkerThreads from "node:worker_threads"; + +import type { WindowsForegroundFocusTarget } from "./WindowsForegroundFocusThread.ts"; +import type { Element } from "@crowecawcaw/xa11y"; + +type FocusRequest = { + readonly type: "prepare" | "focus"; + readonly requestId: number; + readonly target: WindowsForegroundFocusTarget; +}; + +function matchesTarget( + element: { + readonly name?: string | null; + readonly bounds: WindowsForegroundFocusTarget["bounds"] | null; + }, + target: WindowsForegroundFocusTarget, +): boolean { + if ((element.name ?? "").trim() !== target.title.trim()) return false; + if (!element.bounds) return false; + return [target.bounds, target.contentBounds].some((bounds) => + (["x", "y", "width", "height"] as const).every( + (key) => Math.abs(element.bounds![key] - bounds[key]) <= 2, + ), + ); +} + +async function findTarget( + App: (typeof import("@crowecawcaw/xa11y"))["App"], + target: WindowsForegroundFocusTarget, +): Promise { + const app = await App.byPid(target.processId, { timeout: 0 }); + const children = await app.children(); + return ( + children.find((candidate) => matchesTarget(candidate, target)) ?? + (await App.list()) + .filter((candidate) => candidate.pid === target.processId) + .map((candidate) => candidate.asElement()) + .find((candidate) => matchesTarget(candidate, target)) + ); +} + +const cachedElements = new Map(); + +async function prepareTarget( + App: (typeof import("@crowecawcaw/xa11y"))["App"], + target: WindowsForegroundFocusTarget, +): Promise { + const element = await findTarget(App, target); + if (!element) { + cachedElements.delete(target.windowId); + return false; + } + cachedElements.set(target.windowId, element); + return true; +} + +async function focusTarget( + App: (typeof import("@crowecawcaw/xa11y"))["App"], + target: WindowsForegroundFocusTarget, +): Promise { + let element = cachedElements.get(target.windowId); + if (!element) { + element = await findTarget(App, target); + if (!element) return false; + cachedElements.set(target.windowId, element); + } + try { + await element.focus(); + return true; + } catch { + cachedElements.delete(target.windowId); + return false; + } +} + +async function start() { + const { App } = await import("@crowecawcaw/xa11y"); + const parentPort = NodeWorkerThreads.parentPort; + if (!parentPort) return; + let work = Promise.resolve(); + parentPort.on("message", (message: FocusRequest) => { + if (message.type !== "prepare" && message.type !== "focus") return; + work = work.then(async () => { + const focused = await ( + message.type === "prepare" + ? prepareTarget(App, message.target) + : focusTarget(App, message.target) + ).catch(() => false); + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- Node workers do not accept a target origin. + parentPort.postMessage({ type: "result", requestId: message.requestId, focused }); + }); + }); + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- Node workers do not accept a target origin. + parentPort.postMessage("ready"); +} + +void start(); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index 5ec7dd65d1e2..aa3b7c0b4d99 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -97,6 +97,7 @@ function makeElectronWindowLayer(window: ReturnType["wind focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), setMain: () => Effect.void, clearMain: () => Effect.void, + prepareReveal: () => Effect.succeed(false), reveal: () => Effect.void, sendAll: () => Effect.void, destroyAll: Effect.void, diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 53a6dc97f5a4..cd1404a50464 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -111,6 +111,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { focusedMainOrFirst: Effect.succeed(Option.none()), setMain: () => Effect.void, clearMain: () => Effect.void, + prepareReveal: () => Effect.succeed(false), reveal: () => Effect.void, sendAll: (_channel, state) => Effect.sync(() => { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 3d014981d1b4..18c9b1263796 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -84,6 +84,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, + prepareCaptureReveal: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), dispatchWindowCaptureReady: () => Effect.void, zoomMain: (direction) => diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 9cda31c69048..ef2e088951f6 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -263,6 +263,7 @@ function makeTestLayer(input: { focusedMainOrFirst: Ref.get(input.mainWindow), setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), clearMain: () => Ref.set(input.mainWindow, Option.none()), + prepareReveal: () => Effect.succeed(false), reveal: (window) => Effect.sync(() => input.onReveal?.(window)), sendAll: () => Effect.void, destroyAll: Effect.void, @@ -368,6 +369,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n focusedMainOrFirst: currentMainOrFirst, setMain: (window) => Ref.set(mainWindow, Option.some(window)), clearMain: () => Ref.set(mainWindow, Option.none()), + prepareReveal: () => Effect.succeed(false), reveal: (window) => Ref.update(revealedWindows, (windows) => [...windows, window]), sendAll: () => Effect.void, destroyAll: Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index dd6f8822efe3..ed3f5683d01f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -100,6 +100,7 @@ export class DesktopWindow extends Context.Service< // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; + readonly prepareCaptureReveal: Effect.Effect; readonly dispatchMenuAction: ( action: string, options?: { readonly reveal?: boolean }, @@ -880,6 +881,12 @@ export const make = Effect.gen(function* () { createMain, ensureMain, revealOrCreateMain, + prepareCaptureReveal: Effect.gen(function* () { + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + yield* electronWindow.prepareReveal(existingWindow.value); + } + }), activate: Effect.gen(function* () { const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { diff --git a/apps/desktop/src/windowCapture/DesktopWindowCapture.test.ts b/apps/desktop/src/windowCapture/DesktopWindowCapture.test.ts index 7e34e1df8b0a..6aa4db966a2c 100644 --- a/apps/desktop/src/windowCapture/DesktopWindowCapture.test.ts +++ b/apps/desktop/src/windowCapture/DesktopWindowCapture.test.ts @@ -26,6 +26,7 @@ beforeEach(() => { vi.stubEnv("XDG_CURRENT_DESKTOP", "test-desktop"); transitionCapturePageMock.mockReset().mockResolvedValue(undefined); transitionSnapshotMock.mockReset().mockResolvedValue(undefined); + prepareCaptureRevealMock.mockReset(); }); const { @@ -52,6 +53,7 @@ const { openExternalMock, registerShortcutMock, portalShortcutInstances, + prepareCaptureRevealMock, nextPortalState, screenToDipRectMock, screenshotMock, @@ -129,6 +131,7 @@ const { configure: ReturnType; hasSession: boolean; }>, + prepareCaptureRevealMock: vi.fn(), screenToDipRectMock: vi.fn((_window: unknown, bounds: Electron.Rectangle) => bounds), screenshotMock: vi.fn(), shortcutForkArgs: [] as Array>, @@ -478,6 +481,7 @@ const testLayer = ( DesktopWindow.DesktopWindow, DesktopWindow.DesktopWindow.of({ activate: Effect.void, + prepareCaptureReveal: Effect.sync(prepareCaptureRevealMock), dispatchMenuAction: () => Effect.void, dispatchWindowCaptureReady: () => Effect.void, } as unknown as DesktopWindow.DesktopWindow["Service"]), @@ -526,7 +530,14 @@ function concurrentCaptureFixture(platform: NodeJS.Platform, animations: boolean const [first, second, extra] = captures; extra!.pixels.resolve(); extra!.context.resolve({ accessibleText: extra!.title }); - const state = { snapshots: 0, handoffs: 0, failFirstPersistence: false }; + const state = { + snapshots: 0, + handoffs: 0, + preparations: 0, + preparedWithoutOverlay: true, + failNextReveal: false, + failFirstPersistence: false, + }; const images = new Map(); const metadata = new Map(); const readyIds: string[] = []; @@ -636,8 +647,18 @@ function concurrentCaptureFixture(platform: NodeJS.Platform, animations: boolean DesktopWindow.DesktopWindow, DesktopWindow.DesktopWindow.of({ activate: handoff, - dispatchMenuAction: (action: string) => - action.startsWith("window-capture-started:") ? handoff : Effect.void, + prepareCaptureReveal: Effect.sync(() => { + state.preparations++; + state.preparedWithoutOverlay &&= flashWindows.every((window) => window.destroyed); + }), + dispatchMenuAction: (action: string, options?: { readonly reveal?: boolean }) => { + if (!action.startsWith("window-capture-started:")) return Effect.void; + if (state.failNextReveal && options?.reveal !== false) { + state.failNextReveal = false; + return Effect.die("simulated reveal failure"); + } + return handoff; + }, dispatchWindowCaptureReady: (id: string) => Effect.sync(() => { readyIds.push(id); @@ -861,6 +882,10 @@ it.effect.each( const snapshotStarted = Promise.withResolvers(); const snapshotReleased = Promise.withResolvers(); let visible = true; + let preparedWhileVisible: boolean | undefined; + prepareCaptureRevealMock.mockImplementation(() => { + preparedWhileVisible = visible; + }); let blur: () => void = () => undefined; const mainWindow = { getBounds: () => bounds, @@ -971,6 +996,8 @@ it.effect.each( assert.lengthOf(mainWindow.hide.mock.calls, entryPoint === "shortcut" ? 0 : 1); assert.lengthOf(mainWindow.show.mock.calls, entryPoint === "shortcut" ? 0 : 1); assert.lengthOf(mainWindow.restore.mock.calls, 0); + assert.equal(prepareCaptureRevealMock.mock.calls.length, platform === "win32" ? 1 : 0); + if (platform === "win32") assert.isTrue(preparedWhileVisible); if (platform === "linux") { assert.equal(saved.source.appIdentifier, expected.appIdentifier); assert.deepEqual(activate.mock.calls, [[t3.title]]); @@ -1072,6 +1099,8 @@ it.effect.each([ yield* Effect.promise(() => fixture.second.handoff.promise); assert.equal(fixture.state.snapshots, 2); assert.isTrue(fixture.second.oldOverlaysCleared); + assert.equal(fixture.state.preparations, platform === "win32" ? 2 : 0); + assert.isTrue(fixture.state.preparedWithoutOverlay); if (platform === "linux") assert.isTrue(fixture.second.oldNativeFeedbackClosed); const secondOverlays = flashWindows.filter((window) => !window.destroyed); if (platform !== "linux") assert.isNotEmpty(secondOverlays); @@ -1109,6 +1138,26 @@ it.effect.each([ }, ); +it.effect("keeps the capture and animation handoff when reveal defects", () => { + const fixture = concurrentCaptureFixture("win32", true); + fixture.state.failNextReveal = true; + fixture.first.pixels.resolve(); + fixture.first.context.resolve({ accessibleText: fixture.first.title }); + + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopWindowCapture.make; + yield* service.configure(fixture.settings); + yield* Effect.promise(fixture.trigger); + + assert.equal(fixture.state.handoffs, 1); + assert.lengthOf(fixture.readyIds, 1); + const capture = yield* service.read(fixture.readyIds[0]!); + assert.equal(capture.source.windowTitle, fixture.first.title); + }), + ).pipe(Effect.provide(fixture.layer), Effect.ensuring(Effect.sync(fixture.reset))); +}); + it.effect.each(["succeeds", "fails"] as const)( "keeps the newer snapshot exclusive when older persistence %s", (outcome) => { diff --git a/apps/desktop/src/windowCapture/DesktopWindowCapture.ts b/apps/desktop/src/windowCapture/DesktopWindowCapture.ts index a30b933a742f..4a1d00ed2bd4 100644 --- a/apps/desktop/src/windowCapture/DesktopWindowCapture.ts +++ b/apps/desktop/src/windowCapture/DesktopWindowCapture.ts @@ -24,6 +24,7 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -367,6 +368,7 @@ async function captureSource({ kdeCapturePaths, hyprlandCapturePaths, accessibilityProcessPool, + prepareReveal, onLinuxFeedback, }: { target: WindowCaptureTarget; @@ -381,6 +383,7 @@ async function captureSource({ kdeCapturePaths: KdeCapturePaths; hyprlandCapturePaths: HyprlandCapturePaths; accessibilityProcessPool: AccessibilityProcessPool; + prepareReveal: () => Promise; onLinuxFeedback: (feedback: LinuxCaptureFeedback) => void; }) { let active: ActiveWindow | undefined; @@ -394,6 +397,8 @@ async function captureSource({ const destinationWindowBounds = destinationWindow?.getBounds(); let hiddenWindowRestored = false; try { + const revealPreparation = + platform === "win32" ? prepareReveal().catch(() => undefined) : Promise.resolve(); if (hiddenWindow) await hideAndWaitForBlur(hiddenWindow); if (mode === "direct") { active = await activeWindow({ @@ -485,6 +490,7 @@ async function captureSource({ await accessibilityRead.started; } const contextPromise = accessibilityRead?.result ?? Promise.resolve(undefined); + await revealPreparation; if (platform !== "win32" && hiddenWindow && !hiddenWindow.isDestroyed()) { hiddenWindow.show(); hiddenWindowRestored = true; @@ -856,6 +862,7 @@ export const make = Effect.gen(function* () { kdeCapturePaths, hyprlandCapturePaths, accessibilityProcessPool, + prepareReveal: () => runPromise(desktopWindow.prepareCaptureReveal), onLinuxFeedback: (feedback) => { linuxFeedback = { id, feedback }; }, @@ -870,11 +877,15 @@ export const make = Effect.gen(function* () { ); } if (snapshot.animationStarted) { - yield* desktopWindow - .dispatchMenuAction(`window-capture-started:${id}`) - .pipe(Effect.catch(() => Effect.void)); + const action = `window-capture-started:${id}`; + const revealExit = yield* Effect.exit(desktopWindow.dispatchMenuAction(action)); + if (Exit.isFailure(revealExit)) { + yield* desktopWindow + .dispatchMenuAction(action, { reveal: false }) + .pipe(Effect.catchCause(() => Effect.void)); + } } else { - yield* desktopWindow.activate.pipe(Effect.catch(() => Effect.void)); + yield* desktopWindow.activate.pipe(Effect.catchCause(() => Effect.void)); } return { id, capturedAt, ...snapshot }; }).pipe(Effect.mapError((cause) => captureFailure(cause, id))); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index d7e28eaf3875..75fbe29c44cd 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ define: publicConfigDefine, entry: [ "src/main.ts", + "src/electron/WindowsForegroundFocusWorker.ts", "src/windowCapture/GlobalShiftShortcutWorker.ts", "src/windowCapture/WindowCaptureAccessibilityWorker.ts", ],