feat(debug): add native capture diagnostics

This commit is contained in:
webadderall
2026-03-28 16:08:43 +11:00
parent ca29abd89c
commit 045850417f
4 changed files with 211 additions and 4 deletions
+25
View File
@@ -22,6 +22,27 @@ declare namespace NodeJS {
}
// Used in Renderer process, expose in `preload.ts`
interface NativeCaptureDiagnostics {
backend: "windows-wgc" | "mac-screencapturekit" | "browser-store" | "ffmpeg";
phase: "availability" | "start" | "stop" | "mux";
timestamp: string;
sourceId?: string | null;
sourceType?: "screen" | "window" | "unknown";
displayId?: number | null;
displayBounds?: { x: number; y: number; width: number; height: number } | null;
windowHandle?: number | null;
helperPath?: string | null;
outputPath?: string | null;
systemAudioPath?: string | null;
microphonePath?: string | null;
osRelease?: string;
supported?: boolean;
helperExists?: boolean;
fileSizeBytes?: number | null;
processOutput?: string;
error?: string;
}
interface Window {
electronAPI: {
hudOverlayHide: () => void;
@@ -55,6 +76,10 @@ interface Window {
message?: string;
error?: string;
}>;
getLastNativeCaptureDiagnostics: () => Promise<{
success: boolean;
diagnostics?: NativeCaptureDiagnostics | null;
}>;
pauseNativeScreenRecording: () => Promise<{
success: boolean;
message?: string;
+162 -4
View File
@@ -84,6 +84,27 @@ type WindowBounds = {
height: number
}
type NativeCaptureDiagnostics = {
backend: 'windows-wgc' | 'mac-screencapturekit' | 'browser-store' | 'ffmpeg'
phase: 'availability' | 'start' | 'stop' | 'mux'
timestamp: string
sourceId?: string | null
sourceType?: SelectedSource['sourceType'] | 'unknown'
displayId?: number | null
displayBounds?: WindowBounds | null
windowHandle?: number | null
helperPath?: string | null
outputPath?: string | null
systemAudioPath?: string | null
microphonePath?: string | null
osRelease?: string
supported?: boolean
helperExists?: boolean
fileSizeBytes?: number | null
processOutput?: string
error?: string
}
type RecordingSessionData = {
videoPath: string
webcamPath?: string | null
@@ -129,6 +150,7 @@ let windowsCapturePaused = false
let windowsSystemAudioPath: string | null = null
let windowsMicAudioPath: string | null = null
let windowsPendingVideoPath: string | null = null
let lastNativeCaptureDiagnostics: NativeCaptureDiagnostics | null = null
let ffmpegScreenRecordingActive = false
let ffmpegCaptureProcess: ChildProcessWithoutNullStreams | null = null
let ffmpegCaptureOutputBuffer = ''
@@ -238,6 +260,30 @@ async function getRecordingsDir() {
return targetDir
}
function recordNativeCaptureDiagnostics(
diagnostics: Omit<NativeCaptureDiagnostics, 'timestamp'>,
) {
lastNativeCaptureDiagnostics = {
timestamp: new Date().toISOString(),
...diagnostics,
}
return lastNativeCaptureDiagnostics
}
async function getFileSizeIfPresent(filePath: string | null | undefined) {
if (!filePath) {
return null
}
try {
const stat = await fs.stat(filePath)
return stat.size
} catch {
return null
}
}
async function getProjectsDir() {
const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME)
await fs.mkdir(projectsDir, { recursive: true })
@@ -1698,15 +1744,38 @@ function getCursorMonitorExePath() {
async function isNativeWindowsCaptureAvailable(): Promise<boolean> {
if (process.platform !== 'win32') return false
const helperPath = getWindowsCaptureExePath()
const os = await import('node:os')
const [major, , build] = os.release().split('.').map(Number)
const supported = major >= 10 && build >= 19041
let helperExists = false
try {
await fs.access(getWindowsCaptureExePath(), fsConstants.X_OK)
await fs.access(helperPath, fsConstants.X_OK)
helperExists = true
} catch {
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'availability',
helperPath,
helperExists,
osRelease: os.release(),
supported,
error: 'Native Windows capture helper is missing or not executable.',
})
return false
}
const os = await import('node:os')
const [major, , build] = os.release().split('.').map(Number)
return major >= 10 && build >= 19041
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'availability',
helperPath,
helperExists,
osRelease: os.release(),
supported,
})
return supported
}
function waitForWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) {
@@ -3024,6 +3093,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
const recordingsDir = await getRecordingsDir()
const timestamp = Date.now()
const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`)
const displayBounds = source?.id?.startsWith('window:') ? null : getDisplayBoundsForSource(source)
const config: Record<string, unknown> = {
outputPath,
@@ -3070,6 +3140,20 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
}
}
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'start',
sourceId: source?.id ?? null,
sourceType: source?.sourceType ?? 'unknown',
displayId: typeof config.displayId === 'number' ? config.displayId : null,
displayBounds,
windowHandle: typeof config.windowHandle === 'number' ? config.windowHandle : null,
helperPath: exePath,
outputPath,
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
})
windowsCaptureOutputBuffer = ''
windowsCaptureTargetPath = outputPath
windowsCaptureStopRequested = false
@@ -3090,8 +3174,34 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
await waitForWindowsCaptureStart(windowsCaptureProcess)
windowsNativeCaptureActive = true
nativeScreenRecordingActive = true
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'start',
sourceId: source?.id ?? null,
sourceType: source?.sourceType ?? 'unknown',
displayId: typeof config.displayId === 'number' ? config.displayId : null,
displayBounds,
windowHandle: typeof config.windowHandle === 'number' ? config.windowHandle : null,
helperPath: exePath,
outputPath,
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
processOutput: windowsCaptureOutputBuffer.trim() || undefined,
})
return { success: true }
} catch (error) {
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'start',
sourceId: source?.id ?? null,
sourceType: source?.sourceType ?? 'unknown',
helperPath: windowsCaptureTargetPath ? getWindowsCaptureExePath() : null,
outputPath: windowsCaptureTargetPath,
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
processOutput: windowsCaptureOutputBuffer.trim() || undefined,
error: String(error),
})
console.error('Failed to start native Windows capture:', error)
try { windowsCaptureProcess?.kill() } catch { /* ignore */ }
windowsNativeCaptureActive = false
@@ -3255,6 +3365,15 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
}
windowsPendingVideoPath = finalVideoPath
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'stop',
outputPath: finalVideoPath,
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
processOutput: windowsCaptureOutputBuffer.trim() || undefined,
fileSizeBytes: await getFileSizeIfPresent(finalVideoPath),
})
return { success: true, path: finalVideoPath }
} catch (error) {
console.error('Failed to stop native Windows capture:', error)
@@ -3273,12 +3392,32 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
try {
await fs.access(fallbackPath)
windowsPendingVideoPath = fallbackPath
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'stop',
outputPath: fallbackPath,
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
processOutput: windowsCaptureOutputBuffer.trim() || undefined,
fileSizeBytes: await getFileSizeIfPresent(fallbackPath),
error: String(error),
})
return { success: true, path: fallbackPath }
} catch {
// File doesn't exist
}
}
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'stop',
outputPath: fallbackPath,
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
processOutput: windowsCaptureOutputBuffer.trim() || undefined,
error: String(error),
})
return {
success: false,
message: 'Failed to stop native Windows capture',
@@ -3452,6 +3591,10 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
return { available: await isNativeWindowsCaptureAvailable() }
})
ipcMain.handle('get-last-native-capture-diagnostics', async () => {
return { success: true, diagnostics: lastNativeCaptureDiagnostics }
})
ipcMain.handle('mux-native-windows-recording', async () => {
const videoPath = windowsPendingVideoPath
windowsPendingVideoPath = null
@@ -3467,9 +3610,24 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
windowsMicAudioPath = null
}
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'mux',
outputPath: videoPath,
fileSizeBytes: await getFileSizeIfPresent(videoPath),
})
return await finalizeStoredVideo(videoPath)
} catch (error) {
console.error('Failed to mux native Windows recording:', error)
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'mux',
outputPath: videoPath,
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
fileSizeBytes: await getFileSizeIfPresent(videoPath),
error: String(error),
})
windowsSystemAudioPath = null
windowsMicAudioPath = null
try {
+3
View File
@@ -68,6 +68,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
stopNativeScreenRecording: () => {
return ipcRenderer.invoke("stop-native-screen-recording");
},
getLastNativeCaptureDiagnostics: () => {
return ipcRenderer.invoke("get-last-native-capture-diagnostics");
},
pauseNativeScreenRecording: () => {
return ipcRenderer.invoke("pause-native-screen-recording");
},
+21
View File
@@ -94,6 +94,21 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const accumulatedPausedDurationMs = useRef(0);
const pauseStartedAtMs = useRef<number | null>(null);
const logNativeCaptureDiagnostics = useCallback(async (context: string) => {
if (typeof window.electronAPI?.getLastNativeCaptureDiagnostics !== "function") {
return;
}
try {
const result = await window.electronAPI.getLastNativeCaptureDiagnostics();
if (result.success && result.diagnostics) {
console.warn(`[NativeCaptureDiagnostics:${context}]`, result.diagnostics);
}
} catch (error) {
console.warn("Failed to load native capture diagnostics:", error);
}
}, []);
const resetRecordingClock = useCallback((startedAt: number) => {
startTime.current = startedAt;
accumulatedPausedDurationMs.current = 0;
@@ -404,6 +419,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (!result.success || !result.path) {
console.error("Failed to stop native screen recording:", result.error ?? result.message);
void logNativeCaptureDiagnostics("stop-native-screen-recording");
return;
}
@@ -411,6 +427,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (isNativeWindows) {
const muxResult = await window.electronAPI.muxNativeWindowsRecording();
if (!muxResult?.success) {
void logNativeCaptureDiagnostics("mux-native-windows-recording");
}
finalPath = muxResult?.path ?? result.path;
}
@@ -546,6 +565,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const nativeWindowsResult = await window.electronAPI.isNativeWindowsCaptureAvailable();
useNativeWindowsCapture = nativeWindowsResult.available;
if (!useNativeWindowsCapture && !hasShownNativeWindowsFallbackToast.current) {
void logNativeCaptureDiagnostics("is-native-windows-capture-available");
hasShownNativeWindowsFallbackToast.current = true;
toast.info(
"Native Windows capture is unavailable. Falling back to browser capture.",
@@ -586,6 +606,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (!nativeResult.success) {
if (useNativeWindowsCapture) {
console.warn("Native Windows capture failed, falling back to browser capture:", nativeResult.error ?? nativeResult.message);
void logNativeCaptureDiagnostics("start-native-screen-recording");
if (!hasShownNativeWindowsFallbackToast.current) {
hasShownNativeWindowsFallbackToast.current = true;
toast.warning(