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
5 changes: 5 additions & 0 deletions apps/desktop/scripts/dev-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function makeElectronWindowLayer(destroyAll: Effect.Effect<void> = 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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
92 changes: 91 additions & 1 deletion apps/desktop/src/electron/ElectronWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const {
getFocusedWindowMock,
nativeAppByPidMock,
nativeAppListMock,
shellHostedForegroundMock,
windowsForegroundFocusMock,
windowsForegroundPrepareMock,
} = vi.hoisted(() => ({
activeWindowMock: vi.fn(),
activateWindowsForegroundMock: vi.fn(),
Expand All @@ -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", () => ({
Expand Down Expand Up @@ -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(),
};
Expand All @@ -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", () =>
Expand Down Expand Up @@ -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<string> = [];
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) =>
Expand Down Expand Up @@ -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"))),
);

Expand Down
52 changes: 50 additions & 2 deletions apps/desktop/src/electron/ElectronWindow.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<boolean> {
const foreground = await activeWindow().catch(() => undefined);
Expand Down Expand Up @@ -129,6 +144,7 @@ export class ElectronWindow extends Context.Service<
readonly focusedMainOrFirst: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect<void>;
readonly clearMain: (window: Option.Option<Electron.BrowserWindow>) => Effect.Effect<void>;
readonly prepareReveal: (window: Electron.BrowserWindow) => Effect.Effect<boolean>;
readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect<void>;
readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect<void>;
readonly destroyAll: Effect.Effect<void>;
Expand All @@ -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.Option<Electron.BrowserWindow>>(Option.none());

const listWindows = Effect.try({
Expand Down Expand Up @@ -250,13 +272,25 @@ 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 () => {
if (window.isDestroyed()) {
return;
}

const shellHostedForeground =
platform === "win32" && (await isWindowsShellHostedForeground().catch(() => false));

if (window.isMinimized()) {
window.restore();
}
Expand All @@ -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());
}
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/electron/WindowsForeground.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { vi } from "vite-plus/test";

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

Expand All @@ -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,
Expand Down Expand Up @@ -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);
});
});
26 changes: 26 additions & 0 deletions apps/desktop/src/electron/WindowsForeground.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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());
Expand Down Expand Up @@ -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<WindowsForegroundApi> | undefined;

function loadWindowsForegroundApi(): Promise<WindowsForegroundApi> {
Expand All @@ -65,6 +76,17 @@ function loadWindowsForegroundApi(): Promise<WindowsForegroundApi> {
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,
Expand Down Expand Up @@ -99,3 +121,7 @@ export async function activateWindowsForeground(handleBuffer: Buffer): Promise<v
if (activateWindowsForegroundWithApi(handleBuffer, api)) return;
throw new Error("Windows refused to activate the T3 Code window.");
}

export async function isWindowsShellHostedForeground(): Promise<boolean> {
return isWindowsShellHostedForegroundWithApi(await loadWindowsForegroundApi());
}
Loading
Loading