diff --git a/electron/ipc/monitorResolver.ts b/electron/ipc/monitorResolver.ts new file mode 100644 index 000000000..e71f36c11 --- /dev/null +++ b/electron/ipc/monitorResolver.ts @@ -0,0 +1,74 @@ +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", + timeout: 5000, + }); + + 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..699779d3a 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"; @@ -406,10 +407,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 +428,29 @@ 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( + (monitor) => + monitor.x === Math.round(displayBounds.x) && + monitor.y === Math.round(displayBounds.y), + ); + + 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,19 @@ 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); + } else { + setWindowsSystemAudioPath(null); } 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; } @@ -459,6 +482,8 @@ export function registerRecordingHandlers( } else if (browserMicFallbackRequested) { config.captureMic = false; setWindowsMicAudioPath(null); + } else { + setWindowsMicAudioPath(null); } recordNativeCaptureDiagnostics({ @@ -480,19 +505,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); }); @@ -547,6 +578,9 @@ export function registerRecordingHandlers( setNativeScreenRecordingActive(false); setWindowsCaptureProcess(null); setWindowsCaptureTargetPath(null); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + setWindowsOrphanedMicAudioPath(null); setWindowsCaptureStopRequested(false); setWindowsCapturePaused(false); return { @@ -839,11 +873,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);