diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts index fbc7d2d5..1b8b8eaa 100644 --- a/electron/ipc/cursor/bounds.ts +++ b/electron/ipc/cursor/bounds.ts @@ -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 { - 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(); diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index fd598b10..73f62714 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -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); diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index b4cf9f1c..ba40df16 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -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, diff --git a/electron/ipc/windowsCaptureSelection.test.ts b/electron/ipc/windowsCaptureSelection.test.ts index 17039d4d..cfe95410 100644 --- a/electron/ipc/windowsCaptureSelection.test.ts +++ b/electron/ipc/windowsCaptureSelection.test.ts @@ -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, diff --git a/electron/ipc/windowsCaptureSelection.ts b/electron/ipc/windowsCaptureSelection.ts index 3fdf3c00..ac88c65e 100644 --- a/electron/ipc/windowsCaptureSelection.ts +++ b/electron/ipc/windowsCaptureSelection.ts @@ -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"; diff --git a/electron/ipc/windowsWindowControl.ts b/electron/ipc/windowsWindowControl.ts new file mode 100644 index 00000000..9c4c43e8 --- /dev/null +++ b/electron/ipc/windowsWindowControl.ts @@ -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 { + 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 { + 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; + } +}