From 2e5d166fd68e5bcda390b91b03d5bd90f6ae8a01 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 13:50:01 -0400 Subject: [PATCH 1/2] fix(desktop): reveal over Windows shell apps --- apps/desktop/scripts/dev-electron.mjs | 5 + .../src/electron/ElectronWindow.test.ts | 60 ++++++++- apps/desktop/src/electron/ElectronWindow.ts | 42 ++++++- .../src/electron/WindowsForeground.test.ts | 19 +++ .../desktop/src/electron/WindowsForeground.ts | 26 ++++ .../WindowsForegroundFocusThread.test.ts | 60 +++++++++ .../electron/WindowsForegroundFocusThread.ts | 116 ++++++++++++++++++ .../electron/WindowsForegroundFocusWorker.ts | 74 +++++++++++ apps/desktop/vite.config.ts | 1 + 9 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts create mode 100644 apps/desktop/src/electron/WindowsForegroundFocusThread.ts create mode 100644 apps/desktop/src/electron/WindowsForegroundFocusWorker.ts 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/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 9d5451eb9669..d970e9b08d94 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -16,6 +16,8 @@ const { getFocusedWindowMock, nativeAppByPidMock, nativeAppListMock, + shellHostedForegroundMock, + windowsForegroundFocusMock, } = vi.hoisted(() => ({ activeWindowMock: vi.fn(), activateWindowsForegroundMock: vi.fn(), @@ -25,12 +27,22 @@ const { getFocusedWindowMock: vi.fn(), nativeAppByPidMock: vi.fn(), nativeAppListMock: vi.fn(), + shellHostedForegroundMock: vi.fn(), + windowsForegroundFocusMock: vi.fn(), })); vi.mock("get-windows", () => ({ activeWindow: activeWindowMock })); vi.mock("./WindowsForeground.ts", () => ({ activateWindowsForeground: activateWindowsForegroundMock, + isWindowsShellHostedForeground: shellHostedForegroundMock, +})); + +vi.mock("./WindowsForegroundFocusThread.ts", () => ({ + startWindowsForegroundFocusThread: () => ({ + focus: windowsForegroundFocusMock, + close: () => undefined, + }), })); vi.mock("@crowecawcaw/xa11y", () => ({ @@ -76,6 +88,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 +104,8 @@ describe("ElectronWindow", () => { getFocusedWindowMock.mockReset(); nativeAppByPidMock.mockReset(); nativeAppListMock.mockReset().mockResolvedValue([]); + shellHostedForegroundMock.mockReset().mockResolvedValue(false); + windowsForegroundFocusMock.mockReset().mockResolvedValue(false); }); it.effect("preserves schema-safe creation context and the Electron cause", () => @@ -351,6 +366,50 @@ 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, [ + [ + { + 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.each([4, 8])( "skips native focus only when the foreground matches the %i-byte HWND and process", (handleBytes) => @@ -529,7 +588,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..cfa0e29aea50 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,8 @@ 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"; async function isWindowsBrowserWindowForeground(window: Electron.BrowserWindow): Promise { const foreground = await activeWindow().catch(() => undefined); @@ -140,6 +145,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({ @@ -257,6 +268,9 @@ export const make = Effect.gen(function* () { return; } + const shellHostedForeground = + platform === "win32" && (await isWindowsShellHostedForeground().catch(() => false)); + if (window.isMinimized()) { window.restore(); } @@ -280,10 +294,34 @@ export const make = Effect.gen(function* () { window.focus(); if (platform === "win32") { + if (shellHostedForeground) { + await windowsForegroundFocus + ?.focus({ + processId: process.pid, + title: window.getTitle(), + bounds: window.getBounds(), + contentBounds: window.getContentBounds(), + }) + .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({ + processId: process.pid, + title: window.getTitle(), + bounds: window.getBounds(), + contentBounds: window.getContentBounds(), + }) + .catch(() => false) + : false; + if (needsFocus && !focused) { + 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..07aad0728d37 --- /dev/null +++ b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts @@ -0,0 +1,60 @@ +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 = { + 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 thread = startWindowsForegroundFocusThread("focus.cjs"); + const focused = thread.focus(target); + + worker.emit("exit", 1); + + assert.isFalse(await focused); +}); diff --git a/apps/desktop/src/electron/WindowsForegroundFocusThread.ts b/apps/desktop/src/electron/WindowsForegroundFocusThread.ts new file mode 100644 index 000000000000..b2527a84e80f --- /dev/null +++ b/apps/desktop/src/electron/WindowsForegroundFocusThread.ts @@ -0,0 +1,116 @@ +// @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 processId: number; + readonly title: string; + readonly bounds: Electron.Rectangle; + readonly contentBounds: Electron.Rectangle; +}; + +export type WindowsForegroundFocusThread = { + readonly focus: (target: WindowsForegroundFocusTarget) => Promise; + readonly close: () => void; +}; + +type FocusRequest = { + readonly type: "focus"; + readonly requestId: number; + readonly target: WindowsForegroundFocusTarget; +}; + +type FocusResult = { + readonly type: "result"; + readonly requestId: number; + readonly focused: boolean; +}; + +const unavailableThread = (): WindowsForegroundFocusThread => ({ + focus: async () => false, + close: () => undefined, +}); + +export function startWindowsForegroundFocusThread( + workerPath: string, +): WindowsForegroundFocusThread { + let worker: NodeWorkerThreads.Worker; + try { + worker = new NodeWorkerThreads.Worker(workerPath); + worker.unref(); + } catch { + return unavailableThread(); + } + + let ready = false; + let closed = false; + let nextRequestId = 1; + const queued = new Map(); + const pending = new Map< + number, + { + readonly resolve: (focused: boolean) => void; + readonly timeout: ReturnType; + } + >(); + + const finish = (requestId: number, focused: boolean) => { + queued.delete(requestId); + const request = pending.get(requestId); + if (!request) return; + pending.delete(requestId); + clearTimeout(request.timeout); + request.resolve(focused); + }; + const send = (request: FocusRequest) => { + if (!ready || closed) return; + queued.delete(request.requestId); + try { + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- Node workers do not accept a target origin. + worker.postMessage(request); + } catch { + finish(request.requestId, false); + } + }; + const stop = () => { + if (closed) return; + closed = true; + for (const requestId of pending.keys()) finish(requestId, false); + void worker.terminate(); + }; + + worker.on("message", (rawMessage) => { + if (rawMessage === "ready") { + ready = true; + for (const request of queued.values()) send(request); + return; + } + const message = rawMessage as FocusResult; + if (message.type === "result") finish(message.requestId, message.focused); + }); + worker.once("error", stop); + worker.once("exit", stop); + + return { + focus: (target) => + new Promise((resolve) => { + if (closed) { + resolve(false); + return; + } + const requestId = nextRequestId++; + const request = { type: "focus", requestId, target } satisfies FocusRequest; + const timeout = setTimeout(() => finish(requestId, false), FOCUS_TIMEOUT_MS); + timeout.unref(); + queued.set(requestId, request); + pending.set(requestId, { resolve, timeout }); + send(request); + }), + close: stop, + }; +} diff --git a/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts b/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts new file mode 100644 index 000000000000..092faf2a22fd --- /dev/null +++ b/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts @@ -0,0 +1,74 @@ +import * as NodeWorkerThreads from "node:worker_threads"; + +import type { WindowsForegroundFocusTarget } from "./WindowsForegroundFocusThread.ts"; +import type { Element } from "@crowecawcaw/xa11y"; + +type FocusRequest = { + readonly type: "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 focusTarget( + App: (typeof import("@crowecawcaw/xa11y"))["App"], + target: WindowsForegroundFocusTarget, +): Promise { + const key = JSON.stringify(target); + if (cachedElement?.key === key) { + try { + await cachedElement.element.focus(); + return true; + } catch { + cachedElement = undefined; + } + } + const app = await App.byPid(target.processId, { timeout: 0 }); + const children = await app.children(); + const element = + children.find((candidate) => matchesTarget(candidate, target)) ?? + (await App.list()) + .filter((candidate) => candidate.pid === target.processId) + .map((candidate) => candidate.asElement()) + .find((candidate) => matchesTarget(candidate, target)); + if (!element) return false; + cachedElement = { key, element }; + await element.focus(); + return true; +} + +let cachedElement: { readonly key: string; readonly element: Element } | undefined; + +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 !== "focus") return; + work = work.then(async () => { + const focused = await 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/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", ], From 80d0cb2a0f3fadb85d0af92dd5aa0343592a3973 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 17:26:20 -0400 Subject: [PATCH 2/2] fix(desktop): stabilize shell-hosted capture handoff --- apps/desktop/src/app/DesktopLifecycle.test.ts | 2 + .../src/backend/DesktopBackendPool.test.ts | 1 + .../src/electron/ElectronWindow.test.ts | 32 +++++ apps/desktop/src/electron/ElectronWindow.ts | 36 +++-- .../WindowsForegroundFocusThread.test.ts | 40 ++++++ .../electron/WindowsForegroundFocusThread.ts | 124 +++++++++++------- .../electron/WindowsForegroundFocusWorker.ts | 64 ++++++--- .../src/ssh/DesktopSshPasswordPrompts.test.ts | 1 + .../desktop/src/updates/updatesTestHarness.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 2 + apps/desktop/src/window/DesktopWindow.ts | 7 + .../DesktopWindowCapture.test.ts | 55 +++++++- .../src/windowCapture/DesktopWindowCapture.ts | 19 ++- 14 files changed, 299 insertions(+), 86 deletions(-) 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 d970e9b08d94..f23fcb776d4a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -18,6 +18,7 @@ const { nativeAppListMock, shellHostedForegroundMock, windowsForegroundFocusMock, + windowsForegroundPrepareMock, } = vi.hoisted(() => ({ activeWindowMock: vi.fn(), activateWindowsForegroundMock: vi.fn(), @@ -29,6 +30,7 @@ const { nativeAppListMock: vi.fn(), shellHostedForegroundMock: vi.fn(), windowsForegroundFocusMock: vi.fn(), + windowsForegroundPrepareMock: vi.fn(), })); vi.mock("get-windows", () => ({ activeWindow: activeWindowMock })); @@ -40,6 +42,7 @@ vi.mock("./WindowsForeground.ts", () => ({ vi.mock("./WindowsForegroundFocusThread.ts", () => ({ startWindowsForegroundFocusThread: () => ({ + prepare: windowsForegroundPrepareMock, focus: windowsForegroundFocusMock, close: () => undefined, }), @@ -106,6 +109,7 @@ describe("ElectronWindow", () => { 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", () => @@ -400,6 +404,7 @@ describe("ElectronWindow", () => { assert.deepEqual(windowsForegroundFocusMock.mock.calls, [ [ { + windowId: 41, processId: process.pid, title: "T3 Code (Dev)", bounds: { x: 100, y: 50, width: 1_200, height: 800 }, @@ -410,6 +415,33 @@ describe("ElectronWindow", () => { }).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) => diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index cfa0e29aea50..c9d6c2fef3cf 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -18,6 +18,16 @@ import { activeWindow } from "get-windows"; 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); if (window.isDestroyed() || foreground?.owner.processId !== process.pid) return false; @@ -134,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; @@ -261,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 () => { @@ -296,12 +316,7 @@ export const make = Effect.gen(function* () { if (platform === "win32") { if (shellHostedForeground) { await windowsForegroundFocus - ?.focus({ - processId: process.pid, - title: window.getTitle(), - bounds: window.getBounds(), - contentBounds: window.getContentBounds(), - }) + ?.focus(windowsForegroundFocusTarget(window)) .catch(() => false); } try { @@ -311,15 +326,10 @@ export const make = Effect.gen(function* () { const focused = needsFocus && !window.isDestroyed() ? await windowsForegroundFocus - ?.focus({ - processId: process.pid, - title: window.getTitle(), - bounds: window.getBounds(), - contentBounds: window.getContentBounds(), - }) + ?.focus(windowsForegroundFocusTarget(window)) .catch(() => false) : false; - if (needsFocus && !focused) { + if (needsFocus && !focused && !shellHostedForeground) { await focusWindowsBrowserWindow(window).catch(() => undefined); } if (!window.isDestroyed()) { diff --git a/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts index 07aad0728d37..46193ca2c341 100644 --- a/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts +++ b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts @@ -21,6 +21,7 @@ const makeWorker = () => let worker = makeWorker(); const target = { + windowId: 7, processId: 42, title: "T3 Code (Dev)", bounds: { x: 100, y: 50, width: 1_200, height: 800 }, @@ -51,10 +52,49 @@ it("queues focus until the helper is ready", async () => { }); 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 index b2527a84e80f..53e18aaf8c29 100644 --- a/apps/desktop/src/electron/WindowsForegroundFocusThread.ts +++ b/apps/desktop/src/electron/WindowsForegroundFocusThread.ts @@ -8,6 +8,7 @@ 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; @@ -15,12 +16,13 @@ export type WindowsForegroundFocusTarget = { }; export type WindowsForegroundFocusThread = { + readonly prepare: (target: WindowsForegroundFocusTarget) => Promise; readonly focus: (target: WindowsForegroundFocusTarget) => Promise; readonly close: () => void; }; type FocusRequest = { - readonly type: "focus"; + readonly type: "prepare" | "focus"; readonly requestId: number; readonly target: WindowsForegroundFocusTarget; }; @@ -32,6 +34,7 @@ type FocusResult = { }; const unavailableThread = (): WindowsForegroundFocusThread => ({ + prepare: async () => false, focus: async () => false, close: () => undefined, }); @@ -39,78 +42,107 @@ const unavailableThread = (): WindowsForegroundFocusThread => ({ export function startWindowsForegroundFocusThread( workerPath: string, ): WindowsForegroundFocusThread { - let worker: NodeWorkerThreads.Worker; - try { - worker = new NodeWorkerThreads.Worker(workerPath); - worker.unref(); - } catch { - return unavailableThread(); - } - + let worker: NodeWorkerThreads.Worker | undefined; let ready = false; let closed = false; let nextRequestId = 1; - const queued = new Map(); const pending = new Map< number, { + readonly request: FocusRequest; readonly resolve: (focused: boolean) => void; readonly timeout: ReturnType; } >(); const finish = (requestId: number, focused: boolean) => { - queued.delete(requestId); const request = pending.get(requestId); if (!request) return; pending.delete(requestId); clearTimeout(request.timeout); request.resolve(focused); }; - const send = (request: FocusRequest) => { - if (!ready || closed) return; - queued.delete(request.requestId); + 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. - worker.postMessage(request); + targetWorker.postMessage(request); } catch { finish(request.requestId, false); } }; - const stop = () => { - if (closed) return; - closed = true; - for (const requestId of pending.keys()) finish(requestId, false); - void worker.terminate(); + 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; }; - worker.on("message", (rawMessage) => { - if (rawMessage === "ready") { - ready = true; - for (const request of queued.values()) send(request); - return; - } - const message = rawMessage as FocusResult; - if (message.type === "result") finish(message.requestId, message.focused); - }); - worker.once("error", stop); - worker.once("exit", stop); + 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 { - focus: (target) => - new Promise((resolve) => { - if (closed) { - resolve(false); - return; - } - const requestId = nextRequestId++; - const request = { type: "focus", requestId, target } satisfies FocusRequest; - const timeout = setTimeout(() => finish(requestId, false), FOCUS_TIMEOUT_MS); - timeout.unref(); - queued.set(requestId, request); - pending.set(requestId, { resolve, timeout }); - send(request); - }), - close: stop, + 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 index 092faf2a22fd..fb61c25fed78 100644 --- a/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts +++ b/apps/desktop/src/electron/WindowsForegroundFocusWorker.ts @@ -4,7 +4,7 @@ import type { WindowsForegroundFocusTarget } from "./WindowsForegroundFocusThrea import type { Element } from "@crowecawcaw/xa11y"; type FocusRequest = { - readonly type: "focus"; + readonly type: "prepare" | "focus"; readonly requestId: number; readonly target: WindowsForegroundFocusTarget; }; @@ -25,34 +25,54 @@ function matchesTarget( ); } -async function focusTarget( +async function findTarget( App: (typeof import("@crowecawcaw/xa11y"))["App"], target: WindowsForegroundFocusTarget, -): Promise { - const key = JSON.stringify(target); - if (cachedElement?.key === key) { - try { - await cachedElement.element.focus(); - return true; - } catch { - cachedElement = undefined; - } - } +): Promise { const app = await App.byPid(target.processId, { timeout: 0 }); const children = await app.children(); - const element = + 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)); - if (!element) return false; - cachedElement = { key, element }; - await element.focus(); + .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; } -let cachedElement: { readonly key: string; readonly element: Element } | undefined; +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"); @@ -60,9 +80,13 @@ async function start() { if (!parentPort) return; let work = Promise.resolve(); parentPort.on("message", (message: FocusRequest) => { - if (message.type !== "focus") return; + if (message.type !== "prepare" && message.type !== "focus") return; work = work.then(async () => { - const focused = await focusTarget(App, message.target).catch(() => false); + 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 }); }); 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)));