diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 760bbe23..6284fb19 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -9,6 +9,7 @@ import { setWindowsCapturePaused, setWindowsSystemAudioPath, setWindowsMicAudioPath, + setWindowsOrphanedMicAudioPath, setWindowsPendingVideoPath, selectedSource, } from "./state"; @@ -43,6 +44,7 @@ export function killWindowsCaptureProcess() { setWindowsCapturePaused(false); setWindowsSystemAudioPath(null); setWindowsMicAudioPath(null); + setWindowsOrphanedMicAudioPath(null); setWindowsPendingVideoPath(null); } } diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index b4241613..51598e97 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -38,6 +38,8 @@ import { setWindowsSystemAudioPath, windowsMicAudioPath, setWindowsMicAudioPath, + windowsOrphanedMicAudioPath, + setWindowsOrphanedMicAudioPath, windowsPendingVideoPath, setWindowsPendingVideoPath, lastNativeCaptureDiagnostics, @@ -162,6 +164,14 @@ function normalizeDesktopSourceName(value: string) { return value.trim().replace(/\s+/g, " ").toLowerCase(); } +async function cleanupWindowsOrphanedMicAudioPath(filePath: string | null) { + if (!filePath) { + return; + } + + await fs.rm(filePath, { force: true }).catch(() => undefined); +} + export function registerRecordingHandlers( onRecordingStateChange?: (recording: boolean, sourceName: string) => void, ) { @@ -193,12 +203,14 @@ export function registerRecordingHandlers( let captureOutput = '' let systemAudioPath: string | null = null let microphonePath: string | null = null + let orphanedMicAudioPath: string | null = null const resolvedDisplay = resolveWindowsCaptureDisplay( source, getScreen().getAllDisplays(), getScreen().getPrimaryDisplay(), ) const displayBounds = resolvedDisplay.bounds + setWindowsOrphanedMicAudioPath(null) const config: Record = { outputPath, @@ -267,6 +279,8 @@ export function registerRecordingHandlers( options, ) if (microphoneFallbackRequired) { + orphanedMicAudioPath = microphonePath + setWindowsOrphanedMicAudioPath(orphanedMicAudioPath) microphonePath = null setWindowsMicAudioPath(null) } @@ -562,6 +576,7 @@ export function registerRecordingHandlers( const proc = windowsCaptureProcess const preferredVideoPath = windowsCaptureTargetPath + const preferredOrphanedMicAudioPath = windowsOrphanedMicAudioPath setWindowsCaptureStopRequested(true) proc.stdin.write('stop\n') const tempVideoPath = await waitForWindowsCaptureStop(proc) @@ -578,6 +593,8 @@ export function registerRecordingHandlers( setWindowsCaptureTargetPath(null) setWindowsCaptureStopRequested(false) setWindowsCapturePaused(false) + setWindowsOrphanedMicAudioPath(null) + await cleanupWindowsOrphanedMicAudioPath(preferredOrphanedMicAudioPath) setWindowsPendingVideoPath(finalVideoPath) recordNativeCaptureDiagnostics({ backend: 'windows-wgc', @@ -592,6 +609,7 @@ export function registerRecordingHandlers( } catch (error) { console.error('Failed to stop native Windows capture:', error) const fallbackPath = windowsCaptureTargetPath + const fallbackOrphanedMicAudioPath = windowsOrphanedMicAudioPath setWindowsNativeCaptureActive(false) setNativeScreenRecordingActive(false) setWindowsCaptureProcess(null) @@ -600,7 +618,9 @@ export function registerRecordingHandlers( setWindowsCapturePaused(false) setWindowsSystemAudioPath(null) setWindowsMicAudioPath(null) + setWindowsOrphanedMicAudioPath(null) setWindowsPendingVideoPath(null) + await cleanupWindowsOrphanedMicAudioPath(fallbackOrphanedMicAudioPath) if (fallbackPath) { try { @@ -897,7 +917,9 @@ export function registerRecordingHandlers( ipcMain.handle('mux-native-windows-recording', async (_event, pauseSegments?: PauseSegment[]) => { const videoPath = windowsPendingVideoPath + const orphanedMicAudioPath = windowsOrphanedMicAudioPath setWindowsPendingVideoPath(null) + setWindowsOrphanedMicAudioPath(null) if (!videoPath) { return { success: false, message: 'No native Windows video pending for mux' } @@ -916,6 +938,7 @@ export function registerRecordingHandlers( outputPath: videoPath, fileSizeBytes: await getFileSizeIfPresent(videoPath), }) + await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath) return await finalizeStoredVideo(videoPath) } catch (error) { console.error('Failed to mux native Windows recording:', error) @@ -930,10 +953,11 @@ export function registerRecordingHandlers( }) setWindowsSystemAudioPath(null) setWindowsMicAudioPath(null) - return { - success: false, - message: 'Failed to finalize native Windows recording', - error: String(error), + await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath) + try { + return await finalizeStoredVideo(videoPath) + } catch { + return { success: false, message: 'Failed to mux native Windows recording', error: String(error) } } } }) diff --git a/electron/ipc/state.ts b/electron/ipc/state.ts index 7fa98176..19e9ba29 100644 --- a/electron/ipc/state.ts +++ b/electron/ipc/state.ts @@ -44,6 +44,7 @@ export let windowsCaptureStopRequested = false; export let windowsCapturePaused = false; export let windowsSystemAudioPath: string | null = null; export let windowsMicAudioPath: string | null = null; +export let windowsOrphanedMicAudioPath: string | null = null; export let windowsPendingVideoPath: string | null = null; // ── Diagnostics ─────────────────────────────────────────────────────────────── @@ -127,6 +128,7 @@ export function setWindowsCaptureStopRequested(v: boolean) { windowsCaptureStopR export function setWindowsCapturePaused(v: boolean) { windowsCapturePaused = v; } export function setWindowsSystemAudioPath(v: string | null) { windowsSystemAudioPath = v; } export function setWindowsMicAudioPath(v: string | null) { windowsMicAudioPath = v; } +export function setWindowsOrphanedMicAudioPath(v: string | null) { windowsOrphanedMicAudioPath = v; } export function setWindowsPendingVideoPath(v: string | null) { windowsPendingVideoPath = v; } export function setLastNativeCaptureDiagnostics(v: NativeCaptureDiagnostics | null) { lastNativeCaptureDiagnostics = v; } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 2d129916..a4d08d01 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -85,7 +85,29 @@ function getErrorMessage(error: unknown) { return error.message; } - return String(error); + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + if (typeof error === "object" && error !== null) { + try { + const serialized = JSON.stringify(error); + if (serialized && serialized !== "{}") { + return serialized; + } + } catch { + // Ignore stringify failures and fall through to a generic message. + } + + if (typeof (error as { toString?: () => string }).toString === "function") { + const stringified = (error as { toString: () => string }).toString(); + if (stringified && stringified !== "[object Object]") { + return stringified; + } + } + } + + return "An unexpected error occurred"; } export function useScreenRecorder(): UseScreenRecorderReturn { @@ -1046,8 +1068,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { micFallbackRecorder.current = recorder; } catch (micError) { console.warn("Browser microphone fallback failed:", micError); + const permissionDenied = + micError instanceof DOMException && + (micError.name === "NotAllowedError" || + micError.name === "SecurityError"); toast.error( - `${getErrorMessage(micError)}. Recording will continue without microphone audio.`, + permissionDenied + ? "Microphone permission denied. Recording will continue without microphone audio." + : `${getErrorMessage(micError)}. Recording will continue without microphone audio.`, { id: MICROPHONE_FALLBACK_ERROR_TOAST_ID, duration: 10000 }, ); } diff --git a/src/lib/exporter/sourceAudioFallback.test.ts b/src/lib/exporter/sourceAudioFallback.test.ts index bb0b0a03..6de41b51 100644 --- a/src/lib/exporter/sourceAudioFallback.test.ts +++ b/src/lib/exporter/sourceAudioFallback.test.ts @@ -40,4 +40,28 @@ describe("resolveSourceAudioFallbackPaths", () => { externalAudioPaths: ["/tmp/recording.mic.wav"], }); }); -}); \ No newline at end of file + + it("normalizes Windows file URLs and local paths when checking embedded audio", () => { + expect( + resolveSourceAudioFallbackPaths("file:///C:/Users/Egg/Videos/recording.mp4", [ + "C:\\Users\\Egg\\Videos\\recording.mp4", + "C:\\Users\\Egg\\Videos\\recording.mic.wav", + ]), + ).toEqual({ + hasEmbeddedSourceAudio: true, + externalAudioPaths: ["C:\\Users\\Egg\\Videos\\recording.mic.wav"], + }); + }); + + it("matches Windows paths case-insensitively for embedded audio detection", () => { + expect( + resolveSourceAudioFallbackPaths("file:///C:/Users/Egg/Videos/recording.mp4", [ + "c:\\users\\egg\\videos\\recording.mp4", + "c:\\users\\egg\\videos\\recording.mic.wav", + ]), + ).toEqual({ + hasEmbeddedSourceAudio: true, + externalAudioPaths: ["c:\\users\\egg\\videos\\recording.mic.wav"], + }); + }); +}); diff --git a/src/lib/exporter/sourceAudioFallback.ts b/src/lib/exporter/sourceAudioFallback.ts index 0b937614..6e1fcca4 100644 --- a/src/lib/exporter/sourceAudioFallback.ts +++ b/src/lib/exporter/sourceAudioFallback.ts @@ -1,22 +1,48 @@ import { getLocalFilePathFromResource } from "./mediaResource"; +function normalizeSourceAudioFallbackPath(resourceOrPath: string): string | null { + if (typeof resourceOrPath !== "string") { + return null; + } + + const resolvedPath = getLocalFilePathFromResource(resourceOrPath) ?? resourceOrPath; + const trimmedPath = resolvedPath.trim(); + if (!trimmedPath) { + return null; + } + + const isWindowsPath = + /^[A-Za-z]:[\\/]/.test(trimmedPath) || /^\\\\[^\\]+\\[^\\]+/.test(trimmedPath); + if (isWindowsPath) { + return trimmedPath.replace(/\//g, "\\").toLowerCase(); + } + + return trimmedPath.replace(/\\/g, "/"); +} + export function resolveSourceAudioFallbackPaths( videoResource: string | null | undefined, sourceAudioFallbackPaths: string[] | null | undefined, ) { - const normalizedPaths = (sourceAudioFallbackPaths ?? []).filter( - (audioPath) => typeof audioPath === "string" && audioPath.trim().length > 0, - ); + const normalizedPaths = (sourceAudioFallbackPaths ?? []) + .filter((audioPath) => typeof audioPath === "string" && audioPath.trim().length > 0) + .map((audioPath) => ({ + audioPath, + normalizedPath: normalizeSourceAudioFallbackPath(audioPath), + })); const localVideoSourcePath = videoResource - ? getLocalFilePathFromResource(videoResource) + ? normalizeSourceAudioFallbackPath(videoResource) : null; const hasEmbeddedSourceAudio = - Boolean(localVideoSourcePath) && normalizedPaths.includes(localVideoSourcePath); + Boolean(localVideoSourcePath) && + normalizedPaths.some(({ normalizedPath }) => normalizedPath === localVideoSourcePath); return { hasEmbeddedSourceAudio, externalAudioPaths: hasEmbeddedSourceAudio - ? normalizedPaths.filter((audioPath) => audioPath !== localVideoSourcePath) - : normalizedPaths, + ? normalizedPaths + .filter(({ normalizedPath }) => normalizedPath !== localVideoSourcePath) + .map(({ audioPath }) => audioPath) + : normalizedPaths.map(({ audioPath }) => audioPath), }; -} \ No newline at end of file +}