From 713787943ffee5e1bb60569708ba383dd0dc0bba Mon Sep 17 00:00:00 2001 From: Rajin Khan Date: Sun, 20 Sep 2026 18:30:33 +0600 Subject: [PATCH 1/2] fix(mac): surface stopped capture streams --- electron/ipc/recording/mac.ts | 7 +++-- .../native/ScreenCaptureKitRecorder.swift | 18 +++++++++++-- .../native/ScreenCaptureKitRecorder.test.ts | 26 +++++++++++++++++-- 3 files changed, 45 insertions(+), 6 deletions(-) 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/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 6e4b0efdc..e8a029d2b 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 ?? 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..b10f47827 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -6,6 +6,26 @@ const recorderSource = readFileSync( fileURLToPath(new URL("./ScreenCaptureKitRecorder.swift", import.meta.url)), "utf8", ); +const macLifecycleSource = readFileSync( + fileURLToPath(new URL("../ipc/recording/mac.ts", import.meta.url)), + "utf8", +); + +describe("ScreenCaptureKitRecorder stream failures", () => { + it("finalizes and reports a stopped stream to Electron", () => { + expect(recorderSource).toContain('fputs("STREAM_STOPPED: \\(reason)\\n", stderr)'); + expect(recorderSource).toContain("finalizeCapture(interactive: false)"); + expect(macLifecycleSource).toContain("STREAM_STOPPED:"); + expect(macLifecycleSource).toContain('"stream-stopped"'); + }); + + it("honours a configured capture frame rate", () => { + expect(recorderSource).toContain("let requestedFPS = config.fps ?? targetCaptureFPS"); + expect(recorderSource).not.toContain( + "let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS)", + ); + }); +}); describe("ScreenCaptureKitRecorder finalization coordination", () => { it("marks manual stops as participants in the shared finalization", () => { @@ -85,9 +105,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")); From 0f02f3fc78605a4ed798148df8823fe89df06422 Mon Sep 17 00:00:00 2001 From: Rajin Khan Date: Sun, 20 Sep 2026 18:38:23 +0600 Subject: [PATCH 2/2] test: exercise stream-stop lifecycle and validate FPS --- electron/ipc/recording/macLifecycle.test.ts | 40 +++++++++++++++++++ .../native/ScreenCaptureKitRecorder.swift | 2 +- .../native/ScreenCaptureKitRecorder.test.ts | 21 ---------- 3 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 electron/ipc/recording/macLifecycle.test.ts 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 e8a029d2b..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 = 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 diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index b10f47827..621fd58bc 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -6,27 +6,6 @@ const recorderSource = readFileSync( fileURLToPath(new URL("./ScreenCaptureKitRecorder.swift", import.meta.url)), "utf8", ); -const macLifecycleSource = readFileSync( - fileURLToPath(new URL("../ipc/recording/mac.ts", import.meta.url)), - "utf8", -); - -describe("ScreenCaptureKitRecorder stream failures", () => { - it("finalizes and reports a stopped stream to Electron", () => { - expect(recorderSource).toContain('fputs("STREAM_STOPPED: \\(reason)\\n", stderr)'); - expect(recorderSource).toContain("finalizeCapture(interactive: false)"); - expect(macLifecycleSource).toContain("STREAM_STOPPED:"); - expect(macLifecycleSource).toContain('"stream-stopped"'); - }); - - it("honours a configured capture frame rate", () => { - expect(recorderSource).toContain("let requestedFPS = config.fps ?? targetCaptureFPS"); - expect(recorderSource).not.toContain( - "let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS)", - ); - }); -}); - describe("ScreenCaptureKitRecorder finalization coordination", () => { it("marks manual stops as participants in the shared finalization", () => { expect(recorderSource).toContain("finalizeCapture(interactive: true)");