Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions electron/ipc/monitorResolver.ts
Original file line number Diff line number Diff line change
@@ -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<string> GetMonitors() {
List<string> result = new List<string>();
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,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 };
});
}
154 changes: 138 additions & 16 deletions electron/ipc/register/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
systemPreferences,
} from "electron";
import { showCursor } from "../../cursorHider";
import { getMonitorHandles } from "../monitorResolver";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants";
import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds";
import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction";
Expand Down Expand Up @@ -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<string | null | undefined>) {
for (const candidate of candidates) {
if (await pathExists(candidate)) {
return candidate ?? null;
}
}

return null;
}

export function registerRecordingHandlers(
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
) {
Expand Down Expand Up @@ -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;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const browserMicFallbackRequested =
shouldStartWindowsBrowserMicrophoneFallback(options);
const windowId = parseWindowId(source?.id);
Expand All @@ -424,14 +454,29 @@ export function registerRecordingHandlers(
setWindowsOrphanedMicAudioPath(null);

const config: Record<string, unknown> = {
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

config.displayX = Math.round(resolvedDisplay.bounds.x);
config.displayY = Math.round(resolvedDisplay.bounds.y);
config.displayW = Math.round(resolvedDisplay.bounds.width);
Expand All @@ -443,22 +488,31 @@ 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;
}
setWindowsMicAudioPath(microphonePath);
} else if (browserMicFallbackRequested) {
config.captureMic = false;
setWindowsMicAudioPath(null);
} else {
setWindowsMicAudioPath(null);
}

recordNativeCaptureDiagnostics({
Expand All @@ -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);
});

Expand All @@ -501,7 +562,7 @@ export function registerRecordingHandlers(
browserMicFallbackRequested ||
shouldUseWindowsBrowserMicrophoneFallback(captureOutput, options);
if (microphoneFallbackRequired) {
orphanedMicAudioPath = microphonePath;
orphanedMicAudioPath = tempMicPath ?? microphonePath;
setWindowsOrphanedMicAudioPath(orphanedMicAudioPath);
microphonePath = null;
setWindowsMicAudioPath(null);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -937,6 +1054,11 @@ export function registerRecordingHandlers(
}
}

setWindowsSystemAudioPath(null);
setWindowsMicAudioPath(null);
setWindowsPendingVideoPath(null);
await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath);

recordNativeCaptureDiagnostics({
backend: "windows-wgc",
phase: "stop",
Expand Down