diff --git a/electron/ipc/recording/mac.ts b/electron/ipc/recording/mac.ts index 57a9e6881..07e6fbc46 100644 --- a/electron/ipc/recording/mac.ts +++ b/electron/ipc/recording/mac.ts @@ -216,13 +216,16 @@ export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStr } }); + const streamStoppedMatch = nativeCaptureOutputBuffer.match(/STREAM_STOPPED:\s*(.+)/); const reason = nativeCaptureOutputBuffer.includes("WINDOW_UNAVAILABLE") ? "window-unavailable" - : "capture-stopped"; + : streamStoppedMatch + ? "stream-stopped" + : "capture-stopped"; const message = reason === "window-unavailable" ? "The selected window is no longer capturable. Please reselect a window." - : "Recording stopped unexpectedly."; + : streamStoppedMatch?.[1]?.trim() || "Recording stopped unexpectedly."; emitRecordingInterrupted(reason, message); }); diff --git a/electron/ipc/recording/macLifecycle.test.ts b/electron/ipc/recording/macLifecycle.test.ts new file mode 100644 index 000000000..d97f4a45b --- /dev/null +++ b/electron/ipc/recording/macLifecycle.test.ts @@ -0,0 +1,40 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + setNativeCaptureOutputBuffer, + setNativeCaptureStopRequested, + setNativeScreenRecordingActive, +} from "../state"; + +const send = vi.hoisted(() => vi.fn()); +vi.mock("electron", () => ({ + BrowserWindow: { getAllWindows: () => [{ isDestroyed: () => false, webContents: { send } }] }, +})); +vi.mock("../cursor/telemetry", () => ({})); +vi.mock("../utils", () => ({})); +vi.mock("./diagnostics", () => ({})); +vi.mock("./macCompanionAudio", () => ({})); +vi.mock("./prune", () => ({})); + +import { attachNativeCaptureLifecycle } from "./mac"; + +describe("macOS native capture lifecycle", () => { + beforeEach(() => { + send.mockClear(); + setNativeScreenRecordingActive(true); + setNativeCaptureStopRequested(false); + setNativeCaptureOutputBuffer(""); + }); + + it("reports a stopped stream to the renderer with its actual error", () => { + const process = new EventEmitter(); + attachNativeCaptureLifecycle(process as Parameters[0]); + setNativeCaptureOutputBuffer("STREAM_STOPPED: Screen capture permission was revoked\n"); + process.emit("close", 1); + + expect(send).toHaveBeenCalledWith("recording-interrupted", { + reason: "stream-stopped", + message: "Screen capture permission was revoked", + }); + }); +}); diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 6e4b0efdc..fe845b044 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -103,7 +103,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } writesSystemAudioToSeparateTrack = capturesSystemAudio writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone - let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS) + let requestedFPS = config.fps.flatMap { (1...Int(Int32.max)).contains($0) ? $0 : nil } ?? targetCaptureFPS streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS)) streamConfig.queueDepth = 6 streamConfig.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange @@ -513,8 +513,22 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } func stream(_ stream: SCStream, didStopWithError error: Error) { - fputs("Error: \(error.localizedDescription)\n", stderr) + let reason = error.localizedDescription.replacingOccurrences(of: "\n", with: " ") + fputs("STREAM_STOPPED: \(reason)\n", stderr) fflush(stderr) + + Task { [weak self] in + guard let self else { return } + let finalization = await self.finalizeCapture(interactive: false) + if finalization.interactiveStopParticipated { + return + } + if case let .success(outputPath) = finalization.outputResult { + print("Recording stopped. Output path: \(outputPath)") + fflush(stdout) + } + exit(1) + } } /// Starts one finalization operation after all previously delivered samples on diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index 54ae01b51..621fd58bc 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -6,7 +6,6 @@ const recorderSource = readFileSync( fileURLToPath(new URL("./ScreenCaptureKitRecorder.swift", import.meta.url)), "utf8", ); - describe("ScreenCaptureKitRecorder finalization coordination", () => { it("marks manual stops as participants in the shared finalization", () => { expect(recorderSource).toContain("finalizeCapture(interactive: true)"); @@ -85,9 +84,11 @@ describe("ScreenCaptureKitRecorder window capture", () => { }); }); - describe("ScreenCaptureKitRecorder first frame timing", () => { - const callback = recorderSource.slice(recorderSource.indexOf("func stream(_ stream:"), recorderSource.indexOf("func stream(_ stream:") + 5000); + const callback = recorderSource.slice( + recorderSource.indexOf("func stream(_ stream:"), + recorderSource.indexOf("func stream(_ stream:") + 5000, + ); it("validates a complete frame and writer readiness before setting time zero", () => { const clock = callback.indexOf("adjustedPresentationTime(for:"); expect(clock).toBeGreaterThan(callback.indexOf("status == .complete"));