fix(windows): align selected window overlays

This commit is contained in:
webadderall
2026-09-05 11:53:28 +10:00
parent 7d47e614d2
commit bd5aacb120
6 changed files with 171 additions and 103 deletions
+15 -77
View File
@@ -1,20 +1,21 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { NativeMacWindowSource, WindowBounds, SelectedSource } from "../types";
import {
selectedSource,
setSelectedWindowBounds,
interactionCaptureCleanup,
setInteractionCaptureCleanup,
windowBoundsCaptureInterval,
setWindowBoundsCaptureInterval,
cachedNativeMacWindowSources,
setCachedNativeMacWindowSources,
cachedNativeMacWindowSourcesAtMs,
setCachedNativeMacWindowSourcesAtMs,
} from "../state";
import { parseWindowId } from "../utils";
import { ensureNativeWindowListBinary } from "../paths/binaries";
import {
cachedNativeMacWindowSources,
cachedNativeMacWindowSourcesAtMs,
interactionCaptureCleanup,
selectedSource,
setCachedNativeMacWindowSources,
setCachedNativeMacWindowSourcesAtMs,
setInteractionCaptureCleanup,
setSelectedWindowBounds,
setWindowBoundsCaptureInterval,
windowBoundsCaptureInterval,
} from "../state";
import type { NativeMacWindowSource, SelectedSource, WindowBounds } from "../types";
import { parseWindowId } from "../utils";
import { resolveWindowsWindowBounds } from "../windowsWindowControl";
const execFileAsync = promisify(execFile);
@@ -155,69 +156,6 @@ export async function resolveLinuxWindowBounds(
}
}
export async function resolveWindowsWindowBounds(
source: SelectedSource,
): Promise<WindowBounds | null> {
const windowId = parseWindowId(source?.id);
const windowTitle =
typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim();
if (!windowId && !windowTitle) {
return null;
}
const script = [
"param([string]$windowId, [string]$windowTitle)",
'Add-Type -TypeDefinition @"',
"using System;",
"using System.Runtime.InteropServices;",
"public static class RecordlyWindowBounds {",
" [StructLayout(LayoutKind.Sequential)]",
" public struct RECT {",
" public int Left;",
" public int Top;",
" public int Right;",
" public int Bottom;",
" }",
' [DllImport("user32.dll")]',
" [return: MarshalAs(UnmanagedType.Bool)]",
" public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);",
"}",
'"@',
"$handle = [Int64]0",
"if ($windowId) {",
" $handle = [Int64]$windowId",
"}",
"$escapedWindowTitle = if ($windowTitle) { [WildcardPattern]::Escape($windowTitle) } else { $null }",
"if ($handle -le 0 -and $windowTitle) {",
' $matchingProcess = Get-Process | Where-Object { $_.MainWindowTitle -eq $windowTitle -or ($escapedWindowTitle -and $_.MainWindowTitle -like "*$escapedWindowTitle*") } | Select-Object -First 1',
" if ($matchingProcess) {",
" $handle = $matchingProcess.MainWindowHandle.ToInt64()",
" }",
"}",
"if ($handle -le 0) {",
" exit 1",
"}",
"$rect = New-Object RecordlyWindowBounds+RECT",
"if (-not [RecordlyWindowBounds]::GetWindowRect([IntPtr]$handle, [ref]$rect)) {",
" exit 1",
"}",
"@{ x = $rect.Left; y = $rect.Top; width = $rect.Right - $rect.Left; height = $rect.Bottom - $rect.Top } | ConvertTo-Json -Compress",
].join("\n");
try {
const { stdout } = await execFileAsync(
"powershell.exe",
["-NoProfile", "-Command", script, String(windowId ?? ""), windowTitle],
{ timeout: 1500 },
);
const bounds = JSON.parse(stdout) as WindowBounds;
return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null;
} catch {
return null;
}
}
export function stopInteractionCapture() {
if (interactionCaptureCleanup) {
interactionCaptureCleanup();
+4 -4
View File
@@ -179,12 +179,12 @@ export function getNormalizedCursorPoint() {
const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null;
if (windowBounds) {
const sf =
process.platform !== "darwin"
? getScreen().getDisplayNearestPoint({
process.platform === "win32" || process.platform === "darwin"
? 1
: getScreen().getDisplayNearestPoint({
x: windowBounds.x / primarySf,
y: windowBounds.y / primarySf,
}).scaleFactor || 1
: 1;
}).scaleFactor || 1;
const width = Math.max(1, windowBounds.width / sf);
const height = Math.max(1, windowBounds.height / sf);
+17 -22
View File
@@ -7,13 +7,13 @@ import {
getNativeMacWindowSources,
resolveLinuxWindowBounds,
resolveMacWindowBounds,
resolveWindowsWindowBounds,
stopWindowBoundsCapture,
} from "../cursor/bounds";
import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg";
import { selectedSource, setSelectedSource } from "../state";
import type { SelectedSource, WindowBounds } from "../types";
import { getScreen, parseWindowId } from "../utils";
import { bringWindowsWindowForward, resolveWindowsWindowBounds } from "../windowsWindowControl";
import { getScreenSourceIdForDisplay } from "./sourceMapping";
const execFileAsync = promisify(execFile);
@@ -97,23 +97,7 @@ export async function bringSelectedWindowForward(
// App activation still works without macOS Accessibility permission.
}
} else if (process.platform === "win32") {
const script = [
'Add-Type -TypeDefinition @"',
"using System; using System.Runtime.InteropServices;",
"public static class RecordlyForegroundWindow {",
' [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);',
' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);',
"}",
'"@',
"$handle = [IntPtr][Int64]$args[0]",
"[RecordlyForegroundWindow]::ShowWindowAsync($handle, 9) | Out-Null",
"[RecordlyForegroundWindow]::SetForegroundWindow($handle) | Out-Null",
].join("\n");
await execFileAsync(
"powershell.exe",
["-NoProfile", "-Command", script, String(windowId)],
{ timeout: 2000 },
);
await bringWindowsWindowForward(windowId);
} else if (process.platform === "linux") {
await execFileAsync("wmctrl", ["-i", "-a", `0x${windowId.toString(16)}`], {
timeout: 1500,
@@ -434,6 +418,17 @@ export function registerSourceHandlers({
}
}
// A window highlight must never silently become a fullscreen highlight.
// If HWND bounds cannot be resolved, skip the animation and report the
// failure so the next selection can retry with the same window source.
if (isWindow && (!bounds || bounds.width <= 0 || bounds.height <= 0)) {
console.warn("Unable to resolve selected window bounds for highlight", {
sourceId: source.id,
platform: process.platform,
});
return { success: false };
}
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
bounds = getDisplayBoundsForSource(source);
}
@@ -456,10 +451,10 @@ export function registerSourceHandlers({
const isMacScreen = isScreen && process.platform === "darwin";
const pad = isMacScreen ? 0 : 6;
const highlightWin = new BrowserWindow({
x: resolvedBounds.x - pad,
y: resolvedBounds.y - pad,
width: resolvedBounds.width + pad * 2,
height: resolvedBounds.height + pad * 2,
x: Math.round(resolvedBounds.x - pad),
y: Math.round(resolvedBounds.y - pad),
width: Math.max(1, Math.round(resolvedBounds.width + pad * 2)),
height: Math.max(1, Math.round(resolvedBounds.height + pad * 2)),
frame: false,
transparent: true,
alwaysOnTop: true,
@@ -1,10 +1,27 @@
import { describe, expect, it } from "vitest";
import {
convertPhysicalBoundsToDip,
resolveWindowsCaptureDisplay,
resolveWindowsCaptureTarget,
} from "./windowsCaptureSelection";
describe("convertPhysicalBoundsToDip", () => {
it("converts both physical edges so mixed-DPI window sizes stay aligned", () => {
expect(
convertPhysicalBoundsToDip({ x: 240, y: 60, width: 938, height: 1030 }, (point) => ({
x: point.x / 1.5,
y: point.y / 1.5,
})),
).toEqual({
x: 160,
y: 40,
width: 625.3333333333334,
height: 686.6666666666666,
});
});
});
describe("resolveWindowsCaptureDisplay", () => {
const primaryDisplay = {
id: 101,
+18
View File
@@ -21,6 +21,24 @@ export type ResolvedWindowsCaptureDisplay = {
bounds: WindowsCaptureDisplayBounds;
};
export function convertPhysicalBoundsToDip(
bounds: WindowsCaptureDisplayBounds,
convertPoint: (point: { x: number; y: number }) => { x: number; y: number },
): WindowsCaptureDisplayBounds {
const topLeft = convertPoint({ x: bounds.x, y: bounds.y });
const bottomRight = convertPoint({
x: bounds.x + bounds.width,
y: bounds.y + bounds.height,
});
return {
x: topLeft.x,
y: topLeft.y,
width: Math.max(1, bottomRight.x - topLeft.x),
height: Math.max(1, bottomRight.y - topLeft.y),
};
}
export type ResolvedWindowsCaptureTarget =
| {
kind: "window";
+100
View File
@@ -0,0 +1,100 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { SelectedSource, WindowBounds } from "./types";
import { getScreen, parseWindowId } from "./utils";
import { convertPhysicalBoundsToDip } from "./windowsCaptureSelection";
const execFileAsync = promisify(execFile);
export async function bringWindowsWindowForward(windowId: number): Promise<void> {
const script = [
'Add-Type -TypeDefinition @"',
"using System; using System.Runtime.InteropServices;",
"public static class RecordlyForegroundWindow {",
' [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);',
' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);',
' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);',
"}",
'"@',
"$handle = [IntPtr][Int64]$env:RECORDLY_WINDOW_ID",
"if ([RecordlyForegroundWindow]::IsIconic($handle)) { [RecordlyForegroundWindow]::ShowWindowAsync($handle, 9) | Out-Null }",
"[RecordlyForegroundWindow]::SetForegroundWindow($handle) | Out-Null",
].join("\n");
await execFileAsync("powershell.exe", ["-NoProfile", "-Command", script], {
timeout: 5000,
env: { ...process.env, RECORDLY_WINDOW_ID: String(windowId) },
});
}
export async function resolveWindowsWindowBounds(
source: SelectedSource,
): Promise<WindowBounds | null> {
const windowId = parseWindowId(source.id);
const windowTitle =
typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim();
if (!windowId && !windowTitle) return null;
const script = [
"$windowId = $env:RECORDLY_WINDOW_ID",
"$windowTitle = $env:RECORDLY_WINDOW_TITLE",
'Add-Type -TypeDefinition @"',
"using System;",
"using System.Runtime.InteropServices;",
"public static class RecordlyWindowBounds {",
" [StructLayout(LayoutKind.Sequential)]",
" public struct RECT {",
" public int Left;",
" public int Top;",
" public int Right;",
" public int Bottom;",
" }",
' [DllImport("user32.dll")]',
" [return: MarshalAs(UnmanagedType.Bool)]",
" public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);",
' [DllImport("dwmapi.dll")]',
" public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attribute, out RECT rect, int size);",
' [DllImport("user32.dll")]',
" public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);",
"}",
'"@',
"$handle = [Int64]0",
"if ($windowId) { $handle = [Int64]$windowId }",
"$escapedWindowTitle = if ($windowTitle) { [WildcardPattern]::Escape($windowTitle) } else { $null }",
"if ($handle -le 0 -and $windowTitle) {",
' $matchingProcess = Get-Process | Where-Object { $_.MainWindowTitle -eq $windowTitle -or ($escapedWindowTitle -and $_.MainWindowTitle -like "*$escapedWindowTitle*") } | Select-Object -First 1',
" if ($matchingProcess) { $handle = $matchingProcess.MainWindowHandle.ToInt64() }",
"}",
"if ($handle -le 0) { exit 1 }",
"$rect = New-Object RecordlyWindowBounds+RECT",
"[RecordlyWindowBounds]::SetThreadDpiAwarenessContext([IntPtr](-4)) | Out-Null",
"$dwmResult = [RecordlyWindowBounds]::DwmGetWindowAttribute([IntPtr]$handle, 9, [ref]$rect, [Runtime.InteropServices.Marshal]::SizeOf($rect))",
"if ($dwmResult -ne 0 -and -not [RecordlyWindowBounds]::GetWindowRect([IntPtr]$handle, [ref]$rect)) { exit 1 }",
"@{ x = $rect.Left; y = $rect.Top; width = $rect.Right - $rect.Left; height = $rect.Bottom - $rect.Top } | ConvertTo-Json -Compress",
].join("\n");
try {
const { stdout } = await execFileAsync(
"powershell.exe",
["-NoProfile", "-Command", script],
{
timeout: 5000,
env: {
...process.env,
RECORDLY_WINDOW_ID: String(windowId ?? ""),
RECORDLY_WINDOW_TITLE: windowTitle,
},
},
);
const bounds = JSON.parse(stdout) as WindowBounds;
if (!bounds || bounds.width <= 0 || bounds.height <= 0) return null;
const electronScreen = getScreen();
return typeof electronScreen.screenToDipPoint === "function"
? convertPhysicalBoundsToDip(bounds, (point) => electronScreen.screenToDipPoint(point))
: bounds;
} catch {
return null;
}
}