mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
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.
This commit is contained in:
@@ -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<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",
|
||||
});
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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<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(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);
|
||||
|
||||
Reference in New Issue
Block a user