diff --git a/electron/ipc/cursor/bounds.test.ts b/electron/ipc/cursor/bounds.test.ts new file mode 100644 index 000000000..64a17e451 --- /dev/null +++ b/electron/ipc/cursor/bounds.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp"), + isReady: vi.fn(() => true), + }, +})); + +vi.mock("../utils", () => ({ + getScreen: () => ({ + getDisplayNearestPoint: () => ({ scaleFactor: 1.5 }), + }), + parseWindowId: (id?: string | null) => { + const match = id?.match(/^window:(\d+):/); + return match ? Number.parseInt(match[1], 10) : null; + }, +})); + +import { + alignWindowBoundsToWgcCaptureSize, + normalizeWindowsWindowBoundsToElectronDip, + parseWgcCaptureSize, +} from "./bounds"; + +describe("parseWgcCaptureSize", () => { + it("parses capture dimensions from native helper output", () => { + expect( + parseWgcCaptureSize( + "INFO: starting\nCAPTURE_SIZE:1920x1080\nRecording started\n", + ), + ).toEqual({ width: 1920, height: 1080 }); + }); + + it("returns null when capture dimensions are missing", () => { + expect(parseWgcCaptureSize("Recording started")).toBeNull(); + }); +}); + +describe("normalizeWindowsWindowBoundsToElectronDip", () => { + it("reconciles PowerShell bounds when DPI differs from Electron scale factor", () => { + expect( + normalizeWindowsWindowBoundsToElectronDip({ + x: 1440, + y: 900, + width: 1440, + height: 900, + dpi: 96, + }), + ).toEqual({ + x: 960, + y: 600, + width: 960, + height: 600, + }); + }); +}); + +describe("alignWindowBoundsToWgcCaptureSize", () => { + it("keeps window origin and converts WGC physical size to DIP", () => { + expect( + alignWindowBoundsToWgcCaptureSize( + { x: 120, y: 80 }, + { width: 1920, height: 1080 }, + 1.5, + ), + ).toEqual({ + x: 120, + y: 80, + width: 1280, + height: 720, + }); + }); +}); diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts index 02fb3c754..d385a71b0 100644 --- a/electron/ipc/cursor/bounds.ts +++ b/electron/ipc/cursor/bounds.ts @@ -1,9 +1,15 @@ import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import type { NativeMacWindowSource, WindowBounds, SelectedSource } from "../types"; import { selectedSource, + selectedWindowBounds, + selectedWindowsWgcCaptureSize, setSelectedWindowBounds, + setSelectedWindowsWgcCaptureSize, interactionCaptureCleanup, setInteractionCaptureCleanup, windowBoundsCaptureInterval, @@ -13,10 +19,31 @@ import { cachedNativeMacWindowSourcesAtMs, setCachedNativeMacWindowSourcesAtMs, } from "../state"; -import { parseWindowId } from "../utils"; +import { getScreen, parseWindowId } from "../utils"; import { ensureNativeWindowListBinary } from "../paths/binaries"; const execFileAsync = promisify(execFile); +const WINDOWS_WINDOW_BOUNDS_TIMEOUT_MS = 8000; + +function resolveWindowsWindowBoundsScriptPath(): string { + const candidates = [ + process.env.APP_ROOT + ? path.join(process.env.APP_ROOT, "electron", "ipc", "cursor", "get-window-bounds.ps1") + : null, + process.env.APP_ROOT + ? path.join(process.env.APP_ROOT, "dist-electron", "get-window-bounds.ps1") + : null, + path.join(path.dirname(fileURLToPath(import.meta.url)), "get-window-bounds.ps1"), + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return candidates[candidates.length - 1] ?? "get-window-bounds.ps1"; +} export async function getNativeMacWindowSources(options?: { maxAgeMs?: number }) { if (process.platform !== "darwin") { @@ -153,7 +180,141 @@ export async function resolveLinuxWindowBounds(source: SelectedSource): Promise< } } -export async function resolveWindowsWindowBounds(source: SelectedSource): Promise { +export type WindowsWindowBounds = WindowBounds & { + dpi?: number; +}; + +export function parseWgcCaptureSize( + output: string, +): { width: number; height: number } | null { + const match = output.match(/CAPTURE_SIZE:(\d+)x(\d+)/); + if (!match) { + return null; + } + + const width = Number.parseInt(match[1], 10); + const height = Number.parseInt(match[2], 10); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + + return { width, height }; +} + +export function normalizeWindowsWindowBoundsToElectronDip( + bounds: WindowsWindowBounds, +): WindowBounds { + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + const display = getScreen().getDisplayNearestPoint({ x: centerX, y: centerY }); + const electronSf = display.scaleFactor || 1; + const boundsSf = (bounds.dpi ?? 96) / 96; + + if (Math.abs(electronSf - boundsSf) < 0.01) { + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; + } + + const physicalX = bounds.x * boundsSf; + const physicalY = bounds.y * boundsSf; + const physicalWidth = bounds.width * boundsSf; + const physicalHeight = bounds.height * boundsSf; + + return { + x: physicalX / electronSf, + y: physicalY / electronSf, + width: Math.max(1, physicalWidth / electronSf), + height: Math.max(1, physicalHeight / electronSf), + }; +} + +export function alignWindowBoundsToWgcCaptureSize( + bounds: Pick, + captureSize: { width: number; height: number }, + scaleFactor: number, +): WindowBounds { + const scale = scaleFactor > 0 ? scaleFactor : 1; + + return { + x: bounds.x, + y: bounds.y, + width: Math.max(1, captureSize.width / scale), + height: Math.max(1, captureSize.height / scale), + }; +} + +export function resolveWindowsWindowTelemetryBounds( + windowsBounds: WindowsWindowBounds, + captureSize: { width: number; height: number } | null = null, +): WindowBounds { + const dipBounds = normalizeWindowsWindowBoundsToElectronDip(windowsBounds); + if (!captureSize) { + return dipBounds; + } + + const display = getScreen().getDisplayNearestPoint({ + x: dipBounds.x + dipBounds.width / 2, + y: dipBounds.y + dipBounds.height / 2, + }); + + return alignWindowBoundsToWgcCaptureSize( + dipBounds, + captureSize, + display.scaleFactor || 1, + ); +} + +export async function applyWindowsWindowTelemetryBounds( + source: SelectedSource, + captureSize: { width: number; height: number } | null = selectedWindowsWgcCaptureSize, +): Promise { + const windowsBounds = await resolveWindowsWindowBounds(source); + if (!windowsBounds) { + return null; + } + + const telemetryBounds = resolveWindowsWindowTelemetryBounds(windowsBounds, captureSize); + setSelectedWindowBounds(telemetryBounds); + return telemetryBounds; +} + +export async function ensureSelectedWindowBoundsReady(): Promise { + if (!selectedSource?.id?.startsWith("window:")) { + return true; + } + + if (selectedWindowBounds) { + return true; + } + + for (let attempt = 0; attempt < 6; attempt += 1) { + await refreshSelectedWindowBounds(); + if (selectedWindowBounds) { + return true; + } + + if (process.platform === "win32") { + const resolved = await applyWindowsWindowTelemetryBounds(selectedSource); + if (resolved) { + return true; + } + } + + if (attempt < 5) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + + return selectedWindowBounds !== null; +} + +export async function resolveWindowsWindowBounds( + source: SelectedSource, +): Promise { const windowId = parseWindowId(source?.id); const windowTitle = typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim(); @@ -162,53 +323,32 @@ export async function resolveWindowsWindowBounds(source: SelectedSource): Promis 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 }, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + resolveWindowsWindowBoundsScriptPath(), + String(windowId ?? ""), + windowTitle, + ], + { timeout: WINDOWS_WINDOW_BOUNDS_TIMEOUT_MS }, ); - const bounds = JSON.parse(stdout) as WindowBounds; - return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null; + + const trimmedStdout = stdout.trim(); + if (!trimmedStdout) { + return null; + } + + const bounds = JSON.parse(trimmedStdout) as WindowsWindowBounds; + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + return null; + } + + return bounds; } catch { return null; } @@ -227,6 +367,7 @@ export function stopWindowBoundsCapture() { setWindowBoundsCaptureInterval(null); } setSelectedWindowBounds(null); + setSelectedWindowsWgcCaptureSize(null); } async function refreshSelectedWindowBounds() { @@ -240,16 +381,24 @@ async function refreshSelectedWindowBounds() { if (process.platform === "darwin") { bounds = await resolveMacWindowBounds(selectedSource); } else if (process.platform === "win32") { - bounds = await resolveWindowsWindowBounds(selectedSource); + const windowsBounds = await resolveWindowsWindowBounds(selectedSource); + bounds = windowsBounds + ? resolveWindowsWindowTelemetryBounds(windowsBounds, selectedWindowsWgcCaptureSize) + : null; } else if (process.platform === "linux") { bounds = await resolveLinuxWindowBounds(selectedSource); } - setSelectedWindowBounds(bounds); + if (bounds) { + setSelectedWindowBounds(bounds); + } } export function startWindowBoundsCapture() { - stopWindowBoundsCapture(); + if (windowBoundsCaptureInterval) { + clearInterval(windowBoundsCaptureInterval); + setWindowBoundsCaptureInterval(null); + } if ( !["darwin", "win32", "linux"].includes(process.platform) || diff --git a/electron/ipc/cursor/get-window-bounds.ps1 b/electron/ipc/cursor/get-window-bounds.ps1 new file mode 100644 index 000000000..895c71406 --- /dev/null +++ b/electron/ipc/cursor/get-window-bounds.ps1 @@ -0,0 +1,75 @@ +param( + [string]$WindowId, + [string]$WindowTitle +) + +$ErrorActionPreference = "Stop" + +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("user32.dll")] + public static extern uint GetDpiForWindow(IntPtr hWnd); + [DllImport("dwmapi.dll")] + public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); +} +"@ + +$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 { + $_.MainWindowHandle -ne 0 -and ( + $_.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 +$dwmRect = New-Object RecordlyWindowBounds+RECT +$rectSize = 16 +if ([RecordlyWindowBounds]::DwmGetWindowAttribute([IntPtr]$handle, 9, [ref]$dwmRect, $rectSize) -eq 0) { + $rect = $dwmRect +} +elseif (-not [RecordlyWindowBounds]::GetWindowRect([IntPtr]$handle, [ref]$rect)) { + exit 1 +} + +$dpi = [RecordlyWindowBounds]::GetDpiForWindow([IntPtr]$handle) +if ($dpi -le 0) { + $dpi = 96 +} + +$scale = $dpi / 96.0 +@{ + x = [math]::Round($rect.Left / $scale, 4) + y = [math]::Round($rect.Top / $scale, 4) + width = [math]::Round(($rect.Right - $rect.Left) / $scale, 4) + height = [math]::Round(($rect.Bottom - $rect.Top) / $scale, 4) + dpi = $dpi +} | ConvertTo-Json -Compress diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 9b6f3ac92..eb3090ffe 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -185,20 +185,11 @@ export async function startInteractionCapture() { try { const hook = loadUiohookModule(); - console.log( - "[CursorTelemetry] hook loaded:", - !!hook, - "has.on:", - typeof hook?.on, - "has.start:", - typeof hook?.start, - ); if (!isCursorCaptureActive) { return; } if (!hook || typeof hook.on !== "function" || typeof hook.start !== "function") { - console.log("[CursorTelemetry] hook unusable — aborting interaction capture"); return; } diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index ebedfe72a..bb3e4d338 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -20,8 +20,13 @@ import { setCursorCaptureAccumulatedPausedMs, setCursorCaptureInterval, setCursorCapturePauseStartedAtMs, + setCursorCaptureStartTimeMs, + setIsCursorCaptureActive, + setLastLeftClick, + setLinuxCursorScreenPoint, setPendingCursorSamples, } from "../state"; +import { startInteractionCapture, stopInteractionCapture } from "./interaction"; import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types"; import { getScreen, getTelemetryPathForVideo } from "../utils"; @@ -170,7 +175,7 @@ export function getCursorCaptureElapsedMs(nowMs = Date.now()) { ); } -export function getNormalizedCursorPoint() { +export function getNormalizedCursorPoint(): { cx: number; cy: number } | null { const fallbackCursor = getScreen().getCursorScreenPoint(); const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null; const isLinuxCacheFresh = !!linuxCursorCache && Date.now() - linuxCursorCache.updatedAt <= 1000; @@ -182,8 +187,23 @@ export function getNormalizedCursorPoint() { ? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf } : fallbackCursor; - const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; - if (windowBounds) { + const isWindowSource = selectedSource?.id?.startsWith("window:") === true; + const windowBounds = isWindowSource ? selectedWindowBounds : null; + if (isWindowSource) { + if (!windowBounds) { + return null; + } + if (process.platform === "win32") { + // resolveWindowsWindowBounds returns DIP-aligned bounds for WGC/browser window capture + const width = Math.max(1, windowBounds.width); + const height = Math.max(1, windowBounds.height); + + return { + cx: clamp((cursor.x - windowBounds.x) / width, 0, 1), + cy: clamp((cursor.y - windowBounds.y) / height, 0, 1), + }; + } + const sf = process.platform !== "darwin" ? getScreen().getDisplayNearestPoint({ @@ -256,6 +276,10 @@ export function pushCursorSample( export function sampleCursorPoint() { const point = getNormalizedCursorPoint(); + if (!point) { + return; + } + pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "move"); } @@ -292,6 +316,31 @@ export function snapshotCursorTelemetryForPersistence() { ]); } +export function beginCursorCaptureSession({ + startTimeMs = Date.now(), + resetSamples = true, +}: { + startTimeMs?: number; + resetSamples?: boolean; +} = {}) { + stopCursorCapture(); + stopInteractionCapture(); + + if (resetSamples) { + setActiveCursorSamples([]); + setPendingCursorSamples([]); + resetCursorCaptureClock(); + setLinuxCursorScreenPoint(null); + setLastLeftClick(null); + } + + setIsCursorCaptureActive(true); + setCursorCaptureStartTimeMs(startTimeMs); + sampleCursorPoint(); + startCursorSampling(); + void startInteractionCapture(); +} + export function startCursorSampling() { stopCursorCapture(); diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index b13453c74..03feba0d0 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -15,10 +15,17 @@ import { import { showCursor } from "../../cursorHider"; import { getMonitorHandles } from "../monitorResolver"; import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; -import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds"; +import { + applyWindowsWindowTelemetryBounds, + ensureSelectedWindowBoundsReady, + parseWgcCaptureSize, + startWindowBoundsCapture, + stopWindowBoundsCapture, +} from "../cursor/bounds"; import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction"; import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor"; import { + beginCursorCaptureSession, normalizeCursorTelemetrySamples, pauseCursorCaptureAtBoundary, persistPendingCursorTelemetry, @@ -26,7 +33,6 @@ import { resumeCursorCapture, sampleCursorPoint, snapshotCursorTelemetryForPersistence, - startCursorSampling, stopCursorCapture, writeCursorTelemetry, } from "../cursor/telemetry"; @@ -87,7 +93,9 @@ import { ffmpegCaptureProcess, ffmpegCaptureTargetPath, ffmpegScreenRecordingActive, + isCursorCaptureActive, lastNativeCaptureDiagnostics, + nativeVideoRecordingStartedAtMs, nativeCaptureMicrophonePath, nativeCaptureOutputBuffer, nativeCapturePaused, @@ -99,7 +107,7 @@ import { setActiveCursorSamples, setCachedSystemCursorAssets, setCachedSystemCursorAssetsSourceMtimeMs, - setCursorCaptureStartTimeMs, + setNativeVideoRecordingStartedAtMs, setFfmpegCaptureOutputBuffer, setFfmpegCaptureProcess, setFfmpegCaptureTargetPath, @@ -116,6 +124,7 @@ import { setNativeCaptureTargetPath, setNativeScreenRecordingActive, setPendingCursorSamples, + setSelectedWindowsWgcCaptureSize, setWindowsCaptureOutputBuffer, setWindowsCapturePaused, setWindowsCaptureProcess, @@ -354,6 +363,10 @@ function normalizeDesktopSourceName(value: string) { return value.trim().replace(/\s+/g, " ").toLowerCase(); } +function resetNativeVideoRecordingStartAnchor() { + setNativeVideoRecordingStartedAtMs(null); +} + async function cleanupWindowsOrphanedMicAudioPath(filePath: string | null) { if (!filePath) { return; @@ -564,6 +577,15 @@ export function registerRecordingHandlers( }); await waitForWindowsCaptureStart(wcProc); + const videoStartedAtMs = Date.now(); + setNativeVideoRecordingStartedAtMs(videoStartedAtMs); + if (captureTarget.kind === "window") { + const captureSize = parseWgcCaptureSize(captureOutput); + setSelectedWindowsWgcCaptureSize(captureSize); + await applyWindowsWindowTelemetryBounds(source, captureSize); + startWindowBoundsCapture(); + } + beginCursorCaptureSession({ startTimeMs: videoStartedAtMs }); const microphoneFallbackRequired = browserMicFallbackRequested || shouldUseWindowsBrowserMicrophoneFallback(captureOutput, options); @@ -623,6 +645,7 @@ export function registerRecordingHandlers( ]); setWindowsNativeCaptureActive(false); setNativeScreenRecordingActive(false); + resetNativeVideoRecordingStartAnchor(); setWindowsCaptureProcess(null); setWindowsCaptureTargetPath(null); setWindowsSystemAudioPath(null); @@ -766,6 +789,12 @@ export function registerRecordingHandlers( }); await waitForNativeCaptureStart(captProc); + const videoStartedAtMs = Date.now(); + setNativeVideoRecordingStartedAtMs(videoStartedAtMs); + if (source?.id?.startsWith("window:")) { + startWindowBoundsCapture(); + } + beginCursorCaptureSession({ startTimeMs: videoStartedAtMs }); setNativeScreenRecordingActive(true); // If the native helper reported MICROPHONE_CAPTURE_UNAVAILABLE, it started @@ -820,6 +849,7 @@ export function registerRecordingHandlers( /* ignore */ } setNativeScreenRecordingActive(false); + resetNativeVideoRecordingStartAnchor(); setNativeCaptureProcess(null); setNativeCaptureTargetPath(null); setNativeCaptureSystemAudioPath(null); @@ -853,6 +883,7 @@ export function registerRecordingHandlers( /* ignore */ } setNativeScreenRecordingActive(false); + resetNativeVideoRecordingStartAnchor(); setNativeCaptureProcess(null); setNativeCaptureTargetPath(null); setNativeCaptureSystemAudioPath(null); @@ -886,6 +917,7 @@ export function registerRecordingHandlers( // ignore cleanup failures } setNativeScreenRecordingActive(false); + resetNativeVideoRecordingStartAnchor(); setNativeCaptureProcess(null); setNativeCaptureTargetPath(null); setNativeCaptureSystemAudioPath(null); @@ -1810,23 +1842,18 @@ export function registerRecordingHandlers( } }); - ipcMain.handle("set-recording-state", (_, recording: boolean) => { + ipcMain.handle("set-recording-state", async (_, recording: boolean) => { if (recording) { - stopCursorCapture(); - stopInteractionCapture(); - startWindowBoundsCapture(); + if (!isCursorCaptureActive) { + startWindowBoundsCapture(); + await ensureSelectedWindowBoundsReady(); + beginCursorCaptureSession({ + startTimeMs: nativeVideoRecordingStartedAtMs ?? Date.now(), + }); + } void startNativeCursorMonitor(); - setIsCursorCaptureActive(true); - setActiveCursorSamples([]); - setPendingCursorSamples([]); - setCursorCaptureStartTimeMs(Date.now()); - resetCursorCaptureClock(); - setLinuxCursorScreenPoint(null); - setLastLeftClick(null); - sampleCursorPoint(); - startCursorSampling(); - void startInteractionCapture(); } else { + resetNativeVideoRecordingStartAnchor(); setIsCursorCaptureActive(false); stopCursorCapture(); stopInteractionCapture(); diff --git a/electron/ipc/state.ts b/electron/ipc/state.ts index a0a41744e..31d55bfdf 100644 --- a/electron/ipc/state.ts +++ b/electron/ipc/state.ts @@ -76,6 +76,7 @@ export let currentCursorVisualType: CursorVisualType | undefined = undefined; // ── Cursor telemetry ────────────────────────────────────────────────────────── export let cursorCaptureInterval: NodeJS.Timeout | null = null; export let cursorCaptureStartTimeMs = 0; +export let nativeVideoRecordingStartedAtMs: number | null = null; export let cursorCaptureAccumulatedPausedMs = 0; export let cursorCapturePauseStartedAtMs: number | null = null; export let activeCursorSamples: CursorTelemetryPoint[] = []; @@ -86,6 +87,7 @@ export let hasLoggedInteractionHookFailure = false; export let lastLeftClick: { timeMs: number; cx: number; cy: number } | null = null; export let linuxCursorScreenPoint: { x: number; y: number; updatedAt: number } | null = null; export let selectedWindowBounds: WindowBounds | null = null; +export let selectedWindowsWgcCaptureSize: { width: number; height: number } | null = null; export let windowBoundsCaptureInterval: NodeJS.Timeout | null = null; // ── Native macOS window source cache ───────────────────────────────────────── @@ -239,6 +241,9 @@ export function setCursorCaptureInterval(v: NodeJS.Timeout | null) { export function setCursorCaptureStartTimeMs(v: number) { cursorCaptureStartTimeMs = v; } +export function setNativeVideoRecordingStartedAtMs(v: number | null) { + nativeVideoRecordingStartedAtMs = v; +} export function setCursorCaptureAccumulatedPausedMs(v: number) { cursorCaptureAccumulatedPausedMs = v; } @@ -269,6 +274,11 @@ export function setLinuxCursorScreenPoint(v: { x: number; y: number; updatedAt: export function setSelectedWindowBounds(v: WindowBounds | null) { selectedWindowBounds = v; } +export function setSelectedWindowsWgcCaptureSize( + v: { width: number; height: number } | null, +) { + selectedWindowsWgcCaptureSize = v; +} export function setWindowBoundsCaptureInterval(v: NodeJS.Timeout | null) { windowBoundsCaptureInterval = v; } diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index ed16b6a5b..dd924f051 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -6,7 +6,7 @@ import { getScreenCaptureCursorSetting, normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, - resolveLinuxPortalCursorPresentation, + resolveBrowserCursorPresentation, shouldLockHudDuringDisplaySelection, shouldUseLinuxPortalCapture, shouldUseNativeWindowsCaptureForSource, @@ -174,10 +174,10 @@ describe("resolveBrowserCaptureCursorPolicy", () => { }); }); -describe("resolveLinuxPortalCursorPresentation", () => { - it("enables the Recordly overlay only when the portal confirms cursor-hidden capture", () => { +describe("resolveBrowserCursorPresentation", () => { + it("enables the Recordly overlay only when capture confirms cursor-hidden recording", () => { expect( - resolveLinuxPortalCursorPresentation({ + resolveBrowserCursorPresentation({ requestedCursor: "never", actualCursor: "never", }), @@ -187,9 +187,9 @@ describe("resolveLinuxPortalCursorPresentation", () => { }); }); - it("keeps the overlay disabled when the portal embeds or omits cursor settings", () => { + it("keeps the overlay disabled when capture embeds the cursor", () => { expect( - resolveLinuxPortalCursorPresentation({ + resolveBrowserCursorPresentation({ requestedCursor: "never", actualCursor: "always", }), @@ -197,8 +197,24 @@ describe("resolveLinuxPortalCursorPresentation", () => { hideEditorOverlayCursorByDefault: true, nativeCaptureUnavailable: true, }); + }); + + it("keeps the overlay disabled when window capture omits cursor settings", () => { + expect( + resolveBrowserCursorPresentation({ + requestedCursor: "never", + actualCursor: null, + isWindowSource: true, + }), + ).toEqual({ + hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: true, + }); + }); + + it("keeps the overlay disabled when non-window capture omits cursor settings", () => { expect( - resolveLinuxPortalCursorPresentation({ + resolveBrowserCursorPresentation({ requestedCursor: "never", actualCursor: null, }), @@ -289,8 +305,8 @@ describe("shouldUseNativeWindowsCaptureForSource", () => { expect(shouldUseNativeWindowsCaptureForSource({ id: "screen:101:0" })).toBe(true); }); - it("routes window sources through browser capture", () => { - expect(shouldUseNativeWindowsCaptureForSource({ id: "window:123456:0" })).toBe(false); + it("uses native Windows capture for window sources", () => { + expect(shouldUseNativeWindowsCaptureForSource({ id: "window:123456:0" })).toBe(true); }); }); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index bb1f4e3da..66d4dcfe5 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -262,12 +262,14 @@ export function getScreenCaptureCursorSetting( return cursor === "always" || cursor === "never" || cursor === "motion" ? cursor : null; } -export function resolveLinuxPortalCursorPresentation({ +export function resolveBrowserCursorPresentation({ actualCursor, requestedCursor, + isWindowSource = false, }: { actualCursor: BrowserCaptureCursorSetting | null; requestedCursor: BrowserCaptureCursorMode; + isWindowSource?: boolean; }): Pick< BrowserCaptureCursorPolicy, "hideEditorOverlayCursorByDefault" | "nativeCaptureUnavailable" @@ -279,6 +281,13 @@ export function resolveLinuxPortalCursorPresentation({ }; } + if (isWindowSource && actualCursor === null) { + return { + hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: true, + }; + } + return { hideEditorOverlayCursorByDefault: true, nativeCaptureUnavailable: true, @@ -288,7 +297,10 @@ export function resolveLinuxPortalCursorPresentation({ export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { - return source?.id?.startsWith("screen:") === true; + return ( + source?.id?.startsWith("screen:") === true || + source?.id?.startsWith("window:") === true + ); } export function createProcessedMicrophoneConstraints( @@ -1858,11 +1870,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { throw new Error("Media stream is not available."); } - if (useLinuxPortal) { + if (useLinuxPortal || platform === "win32") { const actualCursor = getScreenCaptureCursorSetting(videoTrack.getSettings()); - const cursorPresentation = resolveLinuxPortalCursorPresentation({ + const isWindowSource = selectedSource.id?.startsWith("window:") === true; + const cursorPresentation = resolveBrowserCursorPresentation({ actualCursor, requestedCursor: browserCursorPolicy.streamCursor, + isWindowSource, }); hideEditorOverlayCursorByDefault.current = cursorPresentation.hideEditorOverlayCursorByDefault; @@ -1870,10 +1884,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { cursorPresentation.nativeCaptureUnavailable; if (cursorPresentation.nativeCaptureUnavailable) { console.warn( - "Linux portal did not confirm cursor-hidden capture; disabling Recordly cursor overlay for this recording.", + useLinuxPortal + ? "Linux portal did not confirm cursor-hidden capture; disabling Recordly cursor overlay for this recording." + : "Windows browser capture did not confirm cursor-hidden capture; disabling Recordly cursor overlay for this recording.", { actualCursor, requestedCursor: browserCursorPolicy.streamCursor, + isWindowSource, }, ); } diff --git a/vite.config.ts b/vite.config.ts index 3dd36633d..31f60cc46 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync } from "node:fs"; import path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig, type Plugin } from "vite"; @@ -34,6 +35,19 @@ function electronMainCjsOutputPlugin(): Plugin { }; } +function copyWindowBoundsScriptPlugin(): Plugin { + const sourceScript = path.resolve(__dirname, "electron/ipc/cursor/get-window-bounds.ps1"); + const outputDir = path.resolve(__dirname, "dist-electron"); + + return { + name: "recordly-copy-window-bounds-script", + closeBundle() { + mkdirSync(outputDir, { recursive: true }); + copyFileSync(sourceScript, path.join(outputDir, "get-window-bounds.ps1")); + }, + }; +} + function electronMainCjsGuardPlugin(): Plugin { return { name: "recordly-electron-main-cjs-guard", @@ -79,7 +93,11 @@ export default defineConfig({ }, }, }, - plugins: [electronMainCjsOutputPlugin(), electronMainCjsGuardPlugin()], + plugins: [ + electronMainCjsOutputPlugin(), + copyWindowBoundsScriptPlugin(), + electronMainCjsGuardPlugin(), + ], }, }, preload: {