From 65c832ef1d81429e9936bde03dacd59d39fed474 Mon Sep 17 00:00:00 2001 From: birkls Date: Fri, 15 May 2026 15:25:11 +0200 Subject: [PATCH 1/2] fix(windows): resolve WGC capture failure and non-ASCII path encoding errors - Implemented HMONITOR resolution bridge via PowerShell to match raw handles for native WGC capture. - Added HighDpiAwareness to the capture helper process to fix coordinate mismatches on scaled displays. - Implemented temporary ASCII-safe path shuffle to prevent 'Path Not Found' errors with non-ASCII recordings directories. - Cleaned up debug logging and added professional documentation for PR submission. --- electron/ipc/monitorResolver.ts | 73 ++++++++++++++++++++++++++++++ electron/ipc/register/recording.ts | 68 +++++++++++++++++++++++++--- 2 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 electron/ipc/monitorResolver.ts diff --git a/electron/ipc/monitorResolver.ts b/electron/ipc/monitorResolver.ts new file mode 100644 index 000000000..daaf576e5 --- /dev/null +++ b/electron/ipc/monitorResolver.ts @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process"; + +/** + * Represents a Windows monitor handle and its physical desktop coordinates. + */ +export interface WinMonitorHandle { + handle: number; + x: number; + y: number; + width: number; + height: number; +} + +/** + * Retrieves raw HMONITOR handles from the Windows OS using a PowerShell bridge. + * This is necessary because Electron's display IDs are often internal hashes that + * cannot be used directly with native Windows APIs like Graphics Capture (WGC). + */ +export function getMonitorHandles(): WinMonitorHandle[] { + if (process.platform !== "win32") return []; + + // PowerShell snippet that uses P/Invoke to call EnumDisplayMonitors and return raw handles + bounds. + const psScript = ` +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +using System.Collections.Generic; + +public class MonitorHelper { + [DllImport("user32.dll")] + public static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData); + + public delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref Rect lprcMonitor, IntPtr dwData); + + [StructLayout(LayoutKind.Sequential)] + public struct Rect { + public int left; + public int top; + public int right; + public int bottom; + } + + public static List GetMonitors() { + List result = new List(); + EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (IntPtr hMonitor, IntPtr hdcMonitor, ref Rect lprcMonitor, IntPtr dwData) => { + result.Add(string.Format("{0}|{1}|{2}|{3}|{4}", hMonitor.ToInt64(), lprcMonitor.left, lprcMonitor.top, lprcMonitor.right - lprcMonitor.left, lprcMonitor.bottom - lprcMonitor.top)); + return true; + }, IntPtr.Zero); + return result; + } +} +"@ +[MonitorHelper]::GetMonitors() +`.trim(); + + const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", psScript], { + encoding: "utf-8", + }); + + if (result.error || result.status !== 0) { + // Silent failure is preferred; the caller will fall back to coordinate-based matching. + return []; + } + + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [handle, x, y, width, height] = line.split("|").map(Number); + return { handle, x, y, width, height }; + }); +} diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index feb6aefb1..bd8c89270 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -13,6 +13,7 @@ import { systemPreferences, } from "electron"; import { showCursor } from "../../cursorHider"; +import { getMonitorHandles } from "../monitorResolver"; import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds"; import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction"; @@ -74,6 +75,7 @@ import { waitForWindowsCaptureStart, waitForWindowsCaptureStop, } from "../recording/windows"; +import { getMonitorHandles } from "../monitorResolver"; import { shouldStartWindowsBrowserMicrophoneFallback, shouldUseWindowsBrowserMicrophoneFallback, @@ -406,10 +408,13 @@ export function registerRecordingHandlers( const recordingsDir = await getRecordingsDir(); const timestamp = Date.now(); const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); + const tempVideoPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mp4`); + let captureOutput = ""; let systemAudioPath: string | null = null; let microphonePath: string | null = null; let orphanedMicAudioPath: string | null = null; + const browserMicFallbackRequested = shouldStartWindowsBrowserMicrophoneFallback(options); const windowId = parseWindowId(source?.id); @@ -424,14 +429,28 @@ export function registerRecordingHandlers( setWindowsOrphanedMicAudioPath(null); const config: Record = { - outputPath, + outputPath: tempVideoPath, fps: 60, }; if (isWindowCapture) { config.windowHandle = windowId; } else { - config.displayId = resolvedDisplay.displayId; + // Windows Graphics Capture (WGC) requires a raw HMONITOR handle. + // We attempt to resolve the handle by matching the physical coordinates of the target display. + const monitors = getMonitorHandles(); + const matchedMonitor = monitors.find(m => + m.x === Math.round(displayBounds.x) && + m.y === Math.round(displayBounds.y) + ) || monitors[0]; + + if (matchedMonitor) { + config.displayId = matchedMonitor.handle; + } else { + // Fallback to coordinate-based matching if handle resolution fails + config.displayId = resolvedDisplay.displayId; + } + config.displayX = Math.round(resolvedDisplay.bounds.x); config.displayY = Math.round(resolvedDisplay.bounds.y); config.displayW = Math.round(resolvedDisplay.bounds.width); @@ -443,15 +462,17 @@ export function registerRecordingHandlers( recordingsDir, `recording-${timestamp}.system.wav`, ); + const tempAudioPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.system.wav`); config.captureSystemAudio = true; - config.audioOutputPath = systemAudioPath; + config.audioOutputPath = tempAudioPath; setWindowsSystemAudioPath(systemAudioPath); } if (options?.capturesMicrophone && !browserMicFallbackRequested) { microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`); + const tempMicPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mic.wav`); config.captureMic = true; - config.micOutputPath = microphonePath; + config.micOutputPath = tempMicPath; if (options.microphoneLabel) { config.micDeviceName = options.microphoneLabel; } @@ -480,19 +501,25 @@ export function registerRecordingHandlers( setWindowsCaptureTargetPath(outputPath); setWindowsCaptureStopRequested(false); setWindowsCapturePaused(false); + + // We inject __COMPAT_LAYER=HighDpiAware to ensure the native helper correctly + // calculates coordinates on systems with desktop scaling (DPI) active. wcProc = spawn(exePath, [JSON.stringify(config)], { cwd: recordingsDir, stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, __COMPAT_LAYER: "HighDpiAware" }, }); setWindowsCaptureProcess(wcProc); attachWindowsCaptureLifecycle(wcProc); wcProc.stdout.on("data", (chunk: Buffer) => { - captureOutput += chunk.toString(); + const msg = chunk.toString(); + captureOutput += msg; setWindowsCaptureOutputBuffer(captureOutput); }); wcProc.stderr.on("data", (chunk: Buffer) => { - captureOutput += chunk.toString(); + const msg = chunk.toString(); + captureOutput += msg; setWindowsCaptureOutputBuffer(captureOutput); }); @@ -839,11 +866,38 @@ export function registerRecordingHandlers( setWindowsCaptureStopRequested(true); proc.stdin.write("stop\n"); const tempVideoPath = await waitForWindowsCaptureStop(proc); - const finalVideoPath = preferredVideoPath ?? tempVideoPath; + + // Native Windows capture results are initially written to a safe temporary path + // (to avoid encoding failures with non-ASCII characters). We move them to the final + // destination now using Node.js, which handles Unicode paths correctly. if (tempVideoPath !== finalVideoPath) { await moveFileWithOverwrite(tempVideoPath, finalVideoPath); } + + if (windowsSystemAudioPath && tempVideoPath.endsWith(".mp4")) { + const tempAudioPath = tempVideoPath.replace(".mp4", ".system.wav"); + const finalAudioPath = windowsSystemAudioPath; + if (await fs.access(tempAudioPath).then(() => true).catch(() => false)) { + await moveFileWithOverwrite(tempAudioPath, finalAudioPath); + const tempJson = tempAudioPath + ".json"; + if (await fs.access(tempJson).then(() => true).catch(() => false)) { + await moveFileWithOverwrite(tempJson, finalAudioPath + ".json"); + } + } + } + + if (windowsMicAudioPath && tempVideoPath.endsWith(".mp4")) { + const tempMicPath = tempVideoPath.replace(".mp4", ".mic.wav"); + const finalMicPath = windowsMicAudioPath; + if (await fs.access(tempMicPath).then(() => true).catch(() => false)) { + await moveFileWithOverwrite(tempMicPath, finalMicPath); + const tempJson = tempMicPath + ".json"; + if (await fs.access(tempJson).then(() => true).catch(() => false)) { + await moveFileWithOverwrite(tempJson, finalMicPath + ".json"); + } + } + } const validation = await validateRecordedVideo(finalVideoPath); setWindowsCaptureProcess(null); From 1f04674aec4dbb468bfbe0b49dc6d707a8828c59 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 17 May 2026 19:25:01 +1000 Subject: [PATCH 2/2] fix(windows): address PR 517 review feedback --- electron/ipc/monitorResolver.ts | 1 + electron/ipc/register/recording.ts | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/electron/ipc/monitorResolver.ts b/electron/ipc/monitorResolver.ts index daaf576e5..e71f36c11 100644 --- a/electron/ipc/monitorResolver.ts +++ b/electron/ipc/monitorResolver.ts @@ -55,6 +55,7 @@ public class MonitorHelper { const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", psScript], { encoding: "utf-8", + timeout: 5000, }); if (result.error || result.status !== 0) { diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index bd8c89270..699779d3a 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -75,7 +75,6 @@ import { waitForWindowsCaptureStart, waitForWindowsCaptureStop, } from "../recording/windows"; -import { getMonitorHandles } from "../monitorResolver"; import { shouldStartWindowsBrowserMicrophoneFallback, shouldUseWindowsBrowserMicrophoneFallback, @@ -439,10 +438,11 @@ export function registerRecordingHandlers( // Windows Graphics Capture (WGC) requires a raw HMONITOR handle. // We attempt to resolve the handle by matching the physical coordinates of the target display. const monitors = getMonitorHandles(); - const matchedMonitor = monitors.find(m => - m.x === Math.round(displayBounds.x) && - m.y === Math.round(displayBounds.y) - ) || monitors[0]; + const matchedMonitor = monitors.find( + (monitor) => + monitor.x === Math.round(displayBounds.x) && + monitor.y === Math.round(displayBounds.y), + ); if (matchedMonitor) { config.displayId = matchedMonitor.handle; @@ -466,6 +466,8 @@ export function registerRecordingHandlers( config.captureSystemAudio = true; config.audioOutputPath = tempAudioPath; setWindowsSystemAudioPath(systemAudioPath); + } else { + setWindowsSystemAudioPath(null); } if (options?.capturesMicrophone && !browserMicFallbackRequested) { @@ -480,6 +482,8 @@ export function registerRecordingHandlers( } else if (browserMicFallbackRequested) { config.captureMic = false; setWindowsMicAudioPath(null); + } else { + setWindowsMicAudioPath(null); } recordNativeCaptureDiagnostics({ @@ -574,6 +578,9 @@ export function registerRecordingHandlers( setNativeScreenRecordingActive(false); setWindowsCaptureProcess(null); setWindowsCaptureTargetPath(null); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + setWindowsOrphanedMicAudioPath(null); setWindowsCaptureStopRequested(false); setWindowsCapturePaused(false); return {