Skip to content
Closed
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,
});

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 };
});
}
75 changes: 68 additions & 7 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";
import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants";
import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds";
import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction";
Expand Down Expand Up @@ -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);
Expand All @@ -424,14 +428,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;
}

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 +462,28 @@ 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;
}
setWindowsMicAudioPath(microphonePath);
} else if (browserMicFallbackRequested) {
config.captureMic = false;
setWindowsMicAudioPath(null);
} else {
setWindowsMicAudioPath(null);
}

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

Expand Down Expand Up @@ -547,6 +578,9 @@ export function registerRecordingHandlers(
setNativeScreenRecordingActive(false);
setWindowsCaptureProcess(null);
setWindowsCaptureTargetPath(null);
setWindowsSystemAudioPath(null);
setWindowsMicAudioPath(null);
setWindowsOrphanedMicAudioPath(null);
setWindowsCaptureStopRequested(false);
setWindowsCapturePaused(false);
return {
Expand Down Expand Up @@ -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);
Expand Down