mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
fix(windows): align selected window overlays
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user