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..e88f6f17e 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"; @@ -366,6 +367,29 @@ async function cleanupWindowsOrphanedMicAudioPath(filePath: string | null) { await fs.rm(filePath, { force: true }).catch(() => undefined); } +async function pathExists(filePath: string | null | undefined) { + if (!filePath) { + return false; + } + + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function resolveExistingPath(...candidates: Array) { + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return candidate ?? null; + } + } + + return null; +} + export function registerRecordingHandlers( onRecordingStateChange?: (recording: boolean, sourceName: string) => void, ) { @@ -401,15 +425,21 @@ export function registerRecordingHandlers( } let wcProc: ChildProcessWithoutNullStreams | null = null; + let tempVideoPath: string | null = null; + let tempSystemAudioPath: string | null = null; + let tempMicPath: string | null = null; try { const exePath = getWindowsCaptureExePath(); const recordingsDir = await getRecordingsDir(); const timestamp = Date.now(); const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); + 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 +454,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 +488,22 @@ export function registerRecordingHandlers( recordingsDir, `recording-${timestamp}.system.wav`, ); + tempSystemAudioPath = path.join( + app.getPath("temp"), + `recordly-native-${timestamp}.system.wav`, + ); config.captureSystemAudio = true; - config.audioOutputPath = systemAudioPath; + config.audioOutputPath = tempSystemAudioPath; setWindowsSystemAudioPath(systemAudioPath); + } else { + setWindowsSystemAudioPath(null); } if (options?.capturesMicrophone && !browserMicFallbackRequested) { microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`); + 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 +511,8 @@ export function registerRecordingHandlers( } else if (browserMicFallbackRequested) { config.captureMic = false; setWindowsMicAudioPath(null); + } else { + setWindowsMicAudioPath(null); } recordNativeCaptureDiagnostics({ @@ -480,19 +534,26 @@ export function registerRecordingHandlers( setWindowsCaptureTargetPath(outputPath); setWindowsCaptureStopRequested(false); setWindowsCapturePaused(false); + + // The native helper currently does not declare DPI awareness in its own + // manifest or process setup, so we keep the compatibility flag here until + // scaled-display capture is verified without it on Windows. 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); }); @@ -501,7 +562,7 @@ export function registerRecordingHandlers( browserMicFallbackRequested || shouldUseWindowsBrowserMicrophoneFallback(captureOutput, options); if (microphoneFallbackRequired) { - orphanedMicAudioPath = microphonePath; + orphanedMicAudioPath = tempMicPath ?? microphonePath; setWindowsOrphanedMicAudioPath(orphanedMicAudioPath); microphonePath = null; setWindowsMicAudioPath(null); @@ -543,10 +604,24 @@ export function registerRecordingHandlers( } catch { /* ignore */ } + await Promise.allSettled([ + tempVideoPath + ? fs.rm(tempVideoPath, { force: true }).catch(() => undefined) + : Promise.resolve(), + tempSystemAudioPath + ? fs.rm(tempSystemAudioPath, { force: true }).catch(() => undefined) + : Promise.resolve(), + tempMicPath + ? fs.rm(tempMicPath, { force: true }).catch(() => undefined) + : Promise.resolve(), + ]); setWindowsNativeCaptureActive(false); setNativeScreenRecordingActive(false); setWindowsCaptureProcess(null); setWindowsCaptureTargetPath(null); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + setWindowsOrphanedMicAudioPath(null); setWindowsCaptureStopRequested(false); setWindowsCapturePaused(false); return { @@ -826,6 +901,9 @@ export function registerRecordingHandlers( try { // Windows native capture stop path if (process.platform === "win32" && windowsNativeCaptureActive) { + let stagedTempVideoPath: string | null = null; + let stagedTempSystemAudioPath: string | null = null; + let stagedTempMicAudioPath: string | null = null; try { if (!windowsCaptureProcess) { throw new Error("Native Windows capture process is not running"); @@ -839,11 +917,41 @@ export function registerRecordingHandlers( setWindowsCaptureStopRequested(true); proc.stdin.write("stop\n"); const tempVideoPath = await waitForWindowsCaptureStop(proc); - + stagedTempVideoPath = tempVideoPath; 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"); + stagedTempSystemAudioPath = tempAudioPath; + const finalAudioPath = windowsSystemAudioPath; + if (await pathExists(tempAudioPath)) { + await moveFileWithOverwrite(tempAudioPath, finalAudioPath); + const tempJson = tempAudioPath + ".json"; + if (await pathExists(tempJson)) { + await moveFileWithOverwrite(tempJson, finalAudioPath + ".json"); + } + } + } + + if (windowsMicAudioPath && tempVideoPath.endsWith(".mp4")) { + const tempMicPath = tempVideoPath.replace(".mp4", ".mic.wav"); + stagedTempMicAudioPath = tempMicPath; + const finalMicPath = windowsMicAudioPath; + if (await pathExists(tempMicPath)) { + await moveFileWithOverwrite(tempMicPath, finalMicPath); + const tempJson = tempMicPath + ".json"; + if (await pathExists(tempJson)) { + await moveFileWithOverwrite(tempJson, finalMicPath + ".json"); + } + } + } const validation = await validateRecordedVideo(finalVideoPath); setWindowsCaptureProcess(null); @@ -887,27 +995,36 @@ export function registerRecordingHandlers( return { success: true, path: finalVideoPath }; } catch (error) { console.error("Failed to stop native Windows capture:", error); - const fallbackPath = windowsCaptureTargetPath; + const fallbackPath = await resolveExistingPath( + windowsCaptureTargetPath, + stagedTempVideoPath, + ); + const recoveredSystemAudioPath = await resolveExistingPath( + windowsSystemAudioPath, + stagedTempSystemAudioPath, + ); + const recoveredMicAudioPath = await resolveExistingPath( + windowsMicAudioPath, + stagedTempMicAudioPath, + ); const fallbackOrphanedMicAudioPath = windowsOrphanedMicAudioPath; - const diagnosticsSystemAudioPath = windowsSystemAudioPath; - const diagnosticsMicAudioPath = windowsMicAudioPath; + const diagnosticsSystemAudioPath = recoveredSystemAudioPath ?? windowsSystemAudioPath; + const diagnosticsMicAudioPath = recoveredMicAudioPath ?? windowsMicAudioPath; setWindowsNativeCaptureActive(false); setNativeScreenRecordingActive(false); setWindowsCaptureProcess(null); setWindowsCaptureTargetPath(null); setWindowsCaptureStopRequested(false); setWindowsCapturePaused(false); - setWindowsSystemAudioPath(null); - setWindowsMicAudioPath(null); setWindowsOrphanedMicAudioPath(null); - setWindowsPendingVideoPath(null); - await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); if (fallbackPath) { try { - await fs.access(fallbackPath); const validation = await validateRecordedVideo(fallbackPath); setWindowsPendingVideoPath(fallbackPath); + setWindowsSystemAudioPath(recoveredSystemAudioPath); + setWindowsMicAudioPath(recoveredMicAudioPath); + await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); recordNativeCaptureDiagnostics({ backend: "windows-wgc", phase: "stop", @@ -937,6 +1054,11 @@ export function registerRecordingHandlers( } } + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + setWindowsPendingVideoPath(null); + await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath); + recordNativeCaptureDiagnostics({ backend: "windows-wgc", phase: "stop",