diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 3d976ff4..314d49cc 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -85,6 +85,12 @@ interface Window { message?: string; error?: string; }>; + recoverNativeScreenRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; getLastNativeCaptureDiagnostics: () => Promise<{ success: boolean; diagnostics?: NativeCaptureDiagnostics | null; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 5fb34b43..a793bda5 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2621,6 +2621,34 @@ async function finalizeStoredVideo(videoPath: string) { } } +async function recoverNativeMacCaptureOutput() { + const diagnosticsPath = + lastNativeCaptureDiagnostics?.backend === 'mac-screencapturekit' + ? lastNativeCaptureDiagnostics.outputPath ?? null + : null + const candidatePath = nativeCaptureTargetPath ?? diagnosticsPath + + if (!candidatePath) { + return null + } + + try { + return await finalizeStoredVideo(candidatePath) + } catch (error) { + recordNativeCaptureDiagnostics({ + backend: 'mac-screencapturekit', + phase: 'stop', + outputPath: candidatePath, + systemAudioPath: nativeCaptureSystemAudioPath, + microphonePath: nativeCaptureMicrophonePath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: await getFileSizeIfPresent(candidatePath), + error: String(error), + }) + return null + } +} + async function startInteractionCapture() { if (!isCursorCaptureActive) { return @@ -3370,9 +3398,34 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} await waitForNativeCaptureStart(nativeCaptureProcess) nativeScreenRecordingActive = true + recordNativeCaptureDiagnostics({ + backend: 'mac-screencapturekit', + phase: 'start', + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? 'unknown', + displayId: typeof config.displayId === 'number' ? config.displayId : null, + helperPath, + outputPath, + systemAudioPath: systemAudioOutputPath, + microphonePath: microphoneOutputPath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + }) return { success: true } } catch (error) { console.error('Failed to start native ScreenCaptureKit recording:', error) + recordNativeCaptureDiagnostics({ + backend: 'mac-screencapturekit', + phase: 'start', + sourceId: source?.id ?? null, + sourceType: source?.sourceType ?? 'unknown', + helperPath: getNativeCaptureHelperBinaryPath(), + outputPath: nativeCaptureTargetPath, + systemAudioPath: nativeCaptureSystemAudioPath, + microphonePath: nativeCaptureMicrophonePath, + processOutput: nativeCaptureOutputBuffer.trim() || undefined, + fileSizeBytes: await getFileSizeIfPresent(nativeCaptureTargetPath), + error: String(error), + }) try { nativeCaptureProcess?.kill() } catch { @@ -3485,6 +3538,11 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} } if (!nativeScreenRecordingActive) { + const recovered = await recoverNativeMacCaptureOutput() + if (recovered) { + return recovered + } + return { success: false, message: 'No native screen recording is active.' } } @@ -3525,6 +3583,8 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} } catch (error) { console.error('Failed to stop native ScreenCaptureKit recording:', error) const fallbackPath = nativeCaptureTargetPath + const fallbackSystemAudioPath = nativeCaptureSystemAudioPath + const fallbackMicrophonePath = nativeCaptureMicrophonePath nativeScreenRecordingActive = false nativeCaptureProcess = null nativeCaptureTargetPath = null @@ -3538,12 +3598,28 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} try { await fs.access(fallbackPath) console.log('[stop-native-screen-recording] Recovering with fallback path:', fallbackPath) + if (fallbackSystemAudioPath || fallbackMicrophonePath) { + try { + await muxNativeMacRecordingWithAudio( + fallbackPath, + fallbackSystemAudioPath, + fallbackMicrophonePath, + ) + } catch (muxError) { + console.warn('Failed to mux recovered native macOS audio into capture:', muxError) + } + } return await finalizeStoredVideo(fallbackPath) } catch { // File doesn't exist or isn't accessible } } + const recovered = await recoverNativeMacCaptureOutput() + if (recovered) { + return recovered + } + return { success: false, message: 'Failed to stop native ScreenCaptureKit recording', @@ -3552,6 +3628,22 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} } }) + ipcMain.handle('recover-native-screen-recording', async () => { + if (process.platform !== 'darwin') { + return { success: false, message: 'Native screen recording recovery is only available on macOS.' } + } + + const recovered = await recoverNativeMacCaptureOutput() + if (recovered) { + return recovered + } + + return { + success: false, + message: 'No recoverable native macOS recording output was found.', + } + }) + ipcMain.handle('pause-native-screen-recording', async () => { if (process.platform === 'win32') { if (!windowsNativeCaptureActive || !windowsCaptureProcess) { diff --git a/electron/preload.ts b/electron/preload.ts index 0a07bd97..2f76a239 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -68,6 +68,9 @@ contextBridge.exposeInMainWorld("electronAPI", { stopNativeScreenRecording: () => { return ipcRenderer.invoke("stop-native-screen-recording"); }, + recoverNativeScreenRecording: () => { + return ipcRenderer.invoke("recover-native-screen-recording"); + }, getLastNativeCaptureDiagnostics: () => { return ipcRenderer.invoke("get-last-native-capture-diagnostics"); }, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 3916a631..932f2c6a 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1,5 +1,5 @@ -import { useState, useRef, useEffect, useCallback } from "react"; import { fixWebmDuration } from "@fix-webm-duration/fix"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming"; @@ -91,6 +91,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const pendingWebcamPathPromise = useRef | null>(null); const webcamStopPromise = useRef | null>(null); const webcamStopResolver = useRef<((path: string | null) => void) | null>(null); + const resolvedWebcamPath = useRef(null); const accumulatedPausedDurationMs = useRef(0); const pauseStartedAtMs = useRef(null); @@ -312,12 +313,29 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const result = pending ? await pending : null; + resolvedWebcamPath.current = result; pendingWebcamPathPromise.current = null; return result; }, []); + const recoverNativeRecordingSession = useCallback(async () => { + if (typeof window.electronAPI?.recoverNativeScreenRecording !== "function") { + return null; + } + + const result = await window.electronAPI.recoverNativeScreenRecording(); + if (!result.success || !result.path) { + return null; + } + + const webcamPath = await stopWebcamRecorder(); + await finalizeRecordingSession(result.path, webcamPath); + return result.path; + }, [finalizeRecordingSession, stopWebcamRecorder]); + const startWebcamRecorder = useCallback(async () => { if (!webcamEnabled) { + resolvedWebcamPath.current = null; pendingWebcamPathPromise.current = Promise.resolve(null); webcamStartTime.current = null; webcamTimeOffsetMs.current = 0; @@ -343,6 +361,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const mimeType = selectMimeType(); webcamChunks.current = []; + resolvedWebcamPath.current = null; webcamStopPromise.current = new Promise((resolve) => { webcamStopResolver.current = resolve; }); @@ -401,6 +420,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recorder.start(RECORDER_TIMESLICE_MS); } catch (error) { console.warn("Failed to start webcam recording; continuing without webcam layer:", error); + resolvedWebcamPath.current = null; pendingWebcamPathPromise.current = Promise.resolve(null); webcamStopPromise.current = Promise.resolve(null); webcamRecorder.current = null; @@ -430,6 +450,14 @@ 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"); + try { + const recoveredPath = await recoverNativeRecordingSession(); + if (recoveredPath) { + return; + } + } catch (recoveryError) { + console.error("Failed to recover native screen recording:", recoveryError); + } return; } @@ -501,19 +529,32 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }); const removeRecordingInterruptedListener = window.electronAPI?.onRecordingInterrupted?.((state) => { - setRecording(false); - nativeScreenRecording.current = false; - cleanupCapturedMedia(); - void window.electronAPI.setRecordingState(false); + void (async () => { + setRecording(false); + nativeScreenRecording.current = false; + cleanupCapturedMedia(); + await window.electronAPI.setRecordingState(false); - if (state.reason === "window-unavailable" && !hasPromptedForReselect.current) { - hasPromptedForReselect.current = true; - alert(state.message); - void window.electronAPI.openSourceSelector(); - } else { - console.error(state.message); - toast.error(state.message); - } + if (state.reason !== "window-unavailable") { + try { + const recoveredPath = await recoverNativeRecordingSession(); + if (recoveredPath) { + return; + } + } catch (recoveryError) { + console.error("Failed to recover interrupted native screen recording:", recoveryError); + } + } + + if (state.reason === "window-unavailable" && !hasPromptedForReselect.current) { + hasPromptedForReselect.current = true; + alert(state.message); + await window.electronAPI.openSourceSelector(); + } else { + console.error(state.message); + toast.error(state.message); + } + })(); }); return () => { @@ -532,7 +573,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { cleanupCapturedMedia(); }; - }, [cleanupCapturedMedia]); + }, [cleanupCapturedMedia, recoverNativeRecordingSession]); const startRecording = async () => { if (startInFlight.current) { @@ -853,7 +894,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } if (videoResult.path) { - const webcamPath = await (pendingWebcamPathPromise.current ?? Promise.resolve(null)); + const webcamPath = pendingWebcamPathPromise.current + ? await pendingWebcamPathPromise.current + : resolvedWebcamPath.current; await finalizeRecordingSession(videoResult.path, webcamPath); } } catch (error) { @@ -955,6 +998,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamStream.current?.getTracks().forEach((t) => t.stop()); webcamStream.current = null; pendingWebcamPathPromise.current = null; + resolvedWebcamPath.current = null; if (nativeScreenRecording.current) { nativeScreenRecording.current = false;