import Foundation import ScreenCaptureKit import AVFoundation import CoreGraphics struct CaptureConfig: Codable { let fps: Int? let displayId: CGDirectDisplayID? let windowId: UInt32? let outputPath: String? let capturesSystemAudio: Bool? let capturesMicrophone: Bool? let systemAudioOutputPath: String? let microphoneDeviceId: String? let microphoneLabel: String? let microphoneOutputPath: String? } let targetCaptureFPS = 60 final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private let queue = DispatchQueue(label: "openscreen.screencapturekit.video") private var assetWriter: AVAssetWriter? private var videoInput: AVAssetWriterInput? private var systemAudioWriter: AVAssetWriter? private var systemAudioInput: AVAssetWriterInput? private var microphoneOnlyWriter: AVAssetWriter? private var microphoneOnlyInput: AVAssetWriterInput? private var stream: SCStream? private var firstSampleTime: CMTime = .zero private var firstSystemAudioSampleTime: CMTime? private var firstMicrophoneSampleTime: CMTime? private var lastSampleBuffer: CMSampleBuffer? private var lastVideoPresentationTime: CMTime = .zero private var lastVideoDuration: CMTime = .zero private var isRecording = false private var isPaused = false private var pauseStartedHostTime: CMTime? private var pendingResumeAdjustment = false private var accumulatedPausedDuration: CMTime = .zero private var sessionStarted = false private var frameCount = 0 private var outputURL: URL? private var microphoneOutputURL: URL? private var trackedWindowId: UInt32? private var windowValidationTask: Task? private var inlineAudioInput: AVAssetWriterInput? private var firstInlineAudioSampleTime: CMTime? private var capturesSystemAudio = false private var capturesMicrophone = false private var writesSystemAudioToSeparateTrack = false private var writesMicrophoneToSeparateTrack = false private let microphoneOutputTypeRawValue = 2 func startCapture(configJSON: String) async throws { guard !isRecording else { throw NSError(domain: "RecordlyCapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "Recording is already in progress"]) } guard let data = configJSON.data(using: .utf8) else { throw NSError(domain: "RecordlyCapture", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON input"]) } let config = try JSONDecoder().decode(CaptureConfig.self, from: data) let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) let streamConfig = SCStreamConfiguration() capturesSystemAudio = config.capturesSystemAudio ?? false capturesMicrophone = config.capturesMicrophone ?? false if capturesMicrophone && !supportsNativeMicrophoneCapture(streamConfig: streamConfig) { throw NSError( domain: "RecordlyCapture", code: 10, userInfo: [NSLocalizedDescriptionKey: "Native microphone capture is unavailable on this macOS/Xcode runtime"] ) } writesSystemAudioToSeparateTrack = capturesSystemAudio writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone if capturesMicrophone && !capturesSystemAudio { writesMicrophoneToSeparateTrack = true } let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS) streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS)) streamConfig.queueDepth = 6 streamConfig.pixelFormat = kCVPixelFormatType_32BGRA streamConfig.showsCursor = false streamConfig.capturesAudio = capturesSystemAudio || capturesMicrophone streamConfig.sampleRate = 48000 streamConfig.channelCount = 2 streamConfig.excludesCurrentProcessAudio = true if capturesMicrophone { streamConfig.setValue(true, forKey: "captureMicrophone") if let microphoneDeviceId = Self.resolveMicrophoneCaptureDeviceID(config: config) { streamConfig.setValue(microphoneDeviceId, forKey: "microphoneCaptureDeviceID") } } let filter: SCContentFilter let outputWidth: Int let outputHeight: Int if let windowId = config.windowId { trackedWindowId = windowId guard let window = availableContent.windows.first(where: { $0.windowID == windowId }) else { throw NSError(domain: "RecordlyCapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Window not found"]) } filter = SCContentFilter(desktopIndependentWindow: window) let candidateDisplay = availableContent.displays.first(where: { $0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY)) }) let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID()) outputWidth = max(2, Int(window.frame.width) * scaleFactor) outputHeight = max(2, Int(window.frame.height) * scaleFactor) streamConfig.ignoreShadowsSingleWindow = true streamConfig.width = outputWidth streamConfig.height = outputHeight } else { trackedWindowId = nil let displayId = config.displayId ?? CGMainDisplayID() guard let display = availableContent.displays.first(where: { $0.displayID == displayId }) else { throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Display not found"]) } filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: []) let displayBounds = CGDisplayBounds(display.displayID) let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) outputWidth = max(2, Int(displayBounds.width) * scaleFactor) outputHeight = max(2, Int(displayBounds.height) * scaleFactor) streamConfig.width = outputWidth streamConfig.height = outputHeight } let destinationURL: URL if let outputPath = config.outputPath, !outputPath.isEmpty { destinationURL = URL(fileURLWithPath: outputPath) } else { destinationURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .appendingPathComponent("output_\(Int(Date().timeIntervalSince1970)).mp4") } outputURL = destinationURL let outputFileType: AVFileType = destinationURL.pathExtension.lowercased() == "mp4" ? .mp4 : .mov assetWriter = try AVAssetWriter(url: destinationURL, fileType: outputFileType) microphoneOutputURL = nil firstSystemAudioSampleTime = nil firstMicrophoneSampleTime = nil guard let assistant = AVOutputSettingsAssistant(preset: .preset3840x2160) else { throw NSError(domain: "RecordlyCapture", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unable to create output settings assistant"]) } assistant.sourceVideoFormat = try CMVideoFormatDescription( videoCodecType: .h264, width: outputWidth, height: outputHeight ) guard var outputSettings = assistant.videoSettings else { throw NSError(domain: "RecordlyCapture", code: 6, userInfo: [NSLocalizedDescriptionKey: "Output settings unavailable"]) } outputSettings[AVVideoWidthKey] = outputWidth outputSettings[AVVideoHeightKey] = outputHeight let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) videoInput.expectsMediaDataInRealTime = true guard let assetWriter = assetWriter, assetWriter.canAdd(videoInput) else { throw NSError(domain: "RecordlyCapture", code: 7, userInfo: [NSLocalizedDescriptionKey: "Unable to add video writer input"]) } assetWriter.add(videoInput) self.videoInput = videoInput // Add inline audio track directly to the video so the .mp4 always contains audio. // This eliminates the dependency on the post-recording ffmpeg mux step. if capturesSystemAudio || capturesMicrophone { let inlineAudio = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 192_000)) inlineAudio.expectsMediaDataInRealTime = true if assetWriter.canAdd(inlineAudio) { assetWriter.add(inlineAudio) self.inlineAudioInput = inlineAudio } } if writesSystemAudioToSeparateTrack { guard let systemAudioOutputPath = config.systemAudioOutputPath, !systemAudioOutputPath.isEmpty else { throw NSError(domain: "RecordlyCapture", code: 11, userInfo: [NSLocalizedDescriptionKey: "Missing system audio output path for audio capture"]) } let systemAudioURL = URL(fileURLWithPath: systemAudioOutputPath) let systemAudioWriter = try AVAssetWriter(url: systemAudioURL, fileType: .m4a) let systemAudioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 160_000)) systemAudioInput.expectsMediaDataInRealTime = true guard systemAudioWriter.canAdd(systemAudioInput) else { throw NSError(domain: "RecordlyCapture", code: 12, userInfo: [NSLocalizedDescriptionKey: "Unable to add system audio writer input"]) } systemAudioWriter.add(systemAudioInput) self.systemAudioWriter = systemAudioWriter self.systemAudioInput = systemAudioInput guard systemAudioWriter.startWriting() else { throw NSError(domain: "RecordlyCapture", code: 13, userInfo: [NSLocalizedDescriptionKey: systemAudioWriter.error?.localizedDescription ?? "Unable to start system audio writing"]) } systemAudioWriter.startSession(atSourceTime: .zero) } if writesMicrophoneToSeparateTrack { guard let microphoneOutputPath = config.microphoneOutputPath, !microphoneOutputPath.isEmpty else { throw NSError(domain: "RecordlyCapture", code: 14, userInfo: [NSLocalizedDescriptionKey: "Missing microphone output path for microphone capture"]) } let microphoneURL = URL(fileURLWithPath: microphoneOutputPath) microphoneOutputURL = microphoneURL let microphoneWriter = try AVAssetWriter(url: microphoneURL, fileType: .m4a) let microphoneInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 128_000)) microphoneInput.expectsMediaDataInRealTime = true guard microphoneWriter.canAdd(microphoneInput) else { throw NSError(domain: "RecordlyCapture", code: 15, userInfo: [NSLocalizedDescriptionKey: "Unable to add microphone writer input"]) } microphoneWriter.add(microphoneInput) self.microphoneOnlyWriter = microphoneWriter self.microphoneOnlyInput = microphoneInput guard microphoneWriter.startWriting() else { throw NSError(domain: "RecordlyCapture", code: 16, userInfo: [NSLocalizedDescriptionKey: microphoneWriter.error?.localizedDescription ?? "Unable to start microphone audio writing"]) } microphoneWriter.startSession(atSourceTime: .zero) } let stream = SCStream(filter: filter, configuration: streamConfig, delegate: self) self.stream = stream try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) if capturesSystemAudio { try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue) } if capturesMicrophone { guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else { throw NSError( domain: "RecordlyCapture", code: 17, userInfo: [NSLocalizedDescriptionKey: "Microphone stream output type is unavailable"] ) } try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: queue) } try await stream.startCapture() guard assetWriter.startWriting() else { throw NSError(domain: "RecordlyCapture", code: 8, userInfo: [NSLocalizedDescriptionKey: assetWriter.error?.localizedDescription ?? "Unable to start video writing"]) } assetWriter.startSession(atSourceTime: .zero) sessionStarted = true isRecording = true isPaused = false pauseStartedHostTime = nil pendingResumeAdjustment = false accumulatedPausedDuration = .zero frameCount = 0 firstSampleTime = .zero lastVideoPresentationTime = .zero lastVideoDuration = .zero startWindowValidationIfNeeded() print("Recording started") fflush(stdout) } func stopCapture() async throws -> String { guard isRecording else { throw NSError(domain: "RecordlyCapture", code: 9, userInfo: [NSLocalizedDescriptionKey: "No recording in progress"]) } return try await finishCapture() } func pauseCapture() { guard isRecording, !isPaused else { return } isPaused = true pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock()) pendingResumeAdjustment = false } func resumeCapture() { guard isRecording, isPaused else { return } isPaused = false pendingResumeAdjustment = true } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { guard sessionStarted, sampleBuffer.isValid, isRecording else { return } guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return } if outputType == .screen { guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]], let attachment = attachments.first, let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int, let status = SCFrameStatus(rawValue: statusRawValue), status == .complete else { return } guard let videoInput = videoInput, videoInput.isReadyForMoreMediaData else { return } if firstSampleTime == .zero { firstSampleTime = sampleBuffer.presentationTimeStamp } lastSampleBuffer = sampleBuffer let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { videoInput.append(retimedSampleBuffer) lastVideoPresentationTime = presentationTime lastVideoDuration = sampleBuffer.duration frameCount += 1 } return } if outputType == .audio { guard let systemAudioInput else { return } appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, firstSampleTime: &firstSystemAudioSampleTime, presentationTime: presentationTime) // Also write system audio to the inline video track if let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) } return } if outputType.rawValue == microphoneOutputTypeRawValue { if let microphoneOnlyInput { appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime, presentationTime: presentationTime) } // Write mic to inline video track only if there's no system audio (avoids double-writing) if !capturesSystemAudio, let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) } return } return } func stream(_ stream: SCStream, didStopWithError error: Error) { fputs("Error: \(error.localizedDescription)\n", stderr) fflush(stderr) } private func finishCapture() async throws -> String { windowValidationTask?.cancel() windowValidationTask = nil trackedWindowId = nil isRecording = false if let activeStream = stream { do { try await activeStream.stopCapture() } catch { // Stream may have already been stopped by the system — continue with file finalization } } stream = nil if let originalBuffer = lastSampleBuffer, let videoInput = videoInput { let additionalTime = lastVideoPresentationTime + frameDuration(for: originalBuffer) let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp) if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) { videoInput.append(additionalSampleBuffer) } } assetWriter?.endSession(atSourceTime: lastSampleBuffer?.presentationTimeStamp ?? .zero) videoInput?.markAsFinished() inlineAudioInput?.markAsFinished() await assetWriter?.finishWriting() systemAudioInput?.markAsFinished() await systemAudioWriter?.finishWriting() microphoneOnlyInput?.markAsFinished() await microphoneOnlyWriter?.finishWriting() let path = outputURL?.path ?? "" assetWriter = nil videoInput = nil systemAudioWriter = nil systemAudioInput = nil microphoneOnlyWriter = nil microphoneOnlyInput = nil inlineAudioInput = nil outputURL = nil microphoneOutputURL = nil sessionStarted = false firstSampleTime = .zero firstSystemAudioSampleTime = nil firstMicrophoneSampleTime = nil firstInlineAudioSampleTime = nil lastSampleBuffer = nil lastVideoPresentationTime = .zero lastVideoDuration = .zero frameCount = 0 isPaused = false pauseStartedHostTime = nil pendingResumeAdjustment = false accumulatedPausedDuration = .zero capturesSystemAudio = false capturesMicrophone = false writesSystemAudioToSeparateTrack = false writesMicrophoneToSeparateTrack = false return path } private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? { if isPaused { return nil } let sampleTime = sampleBuffer.presentationTimeStamp if pendingResumeAdjustment, let pauseStartedHostTime { let pauseGap = sampleTime - pauseStartedHostTime if pauseGap > .zero { accumulatedPausedDuration = accumulatedPausedDuration + pauseGap } self.pauseStartedHostTime = nil pendingResumeAdjustment = false } if outputType == .screen { if firstSampleTime == .zero { firstSampleTime = sampleTime } return max(.zero, sampleTime - firstSampleTime - accumulatedPausedDuration) } return sampleTime - accumulatedPausedDuration } private func frameDuration(for sampleBuffer: CMSampleBuffer) -> CMTime { if sampleBuffer.duration.isValid && sampleBuffer.duration > .zero { return sampleBuffer.duration } if lastVideoDuration.isValid && lastVideoDuration > .zero { return lastVideoDuration } return CMTime(value: 1, timescale: CMTimeScale(targetCaptureFPS)) } private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?, presentationTime: CMTime) { guard input.isReadyForMoreMediaData else { return } if firstSampleTime == nil { firstSampleTime = presentationTime } guard let firstSampleTime else { return } let relativePresentationTime = max(.zero, presentationTime - firstSampleTime) let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: relativePresentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { input.append(retimedSampleBuffer) } } private static func audioOutputSettings(bitRate: Int) -> [String: Any] { [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48_000, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: bitRate, ] } private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? { let audioDevices = AVCaptureDevice.devices(for: .audio) if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty { if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) { return matchedDevice.uniqueID } } if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty { if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) { return microphoneDeviceId } } return nil } private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool { let supportsConfigSelector = streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) let supportsDeviceSelector = streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) let supportsOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil return supportsConfigSelector && supportsDeviceSelector && supportsOutputType } private func startWindowValidationIfNeeded() { guard let trackedWindowId else { windowValidationTask?.cancel() windowValidationTask = nil return } windowValidationTask?.cancel() windowValidationTask = Task.detached(priority: .utility) { [weak self] in guard let self else { return } while !Task.isCancelled { try? await Task.sleep(nanoseconds: 500_000_000) if Task.isCancelled { return } guard self.isRecording else { return } do { let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) let windowStillAvailable = availableContent.windows.contains(where: { $0.windowID == trackedWindowId }) if !windowStillAvailable { print("WINDOW_UNAVAILABLE") fflush(stdout) let outputPath = try await self.finishCapture() print("Recording stopped. Output path: \(outputPath)") fflush(stdout) exit(0) } } catch { continue } } } } private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int { guard let mode = CGDisplayCopyDisplayMode(displayId) else { return 1 } return max(1, mode.pixelWidth / max(1, mode.width)) } } final class RecorderService { private let recorder = ScreenCaptureRecorder() private let queue = DispatchQueue(label: "openscreen.screencapturekit.commands") private let completionGroup = DispatchGroup() func start(configJSON: String) { completionGroup.enter() queue.async { Task { do { try await self.recorder.startCapture(configJSON: configJSON) } catch { fputs("Error starting capture: \(error.localizedDescription)\n", stderr) fflush(stderr) self.completionGroup.leave() } } } } func stop() { queue.async { Task { do { let outputPath = try await self.recorder.stopCapture() print("Recording stopped. Output path: \(outputPath)") fflush(stdout) self.completionGroup.leave() } catch { fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) fflush(stderr) self.completionGroup.leave() } } } } func pause() { queue.async { self.recorder.pauseCapture() } } func resume() { queue.async { self.recorder.resumeCapture() } } func waitUntilFinished() { completionGroup.wait() } } guard CommandLine.arguments.count >= 2 else { fputs("Missing config JSON\n", stderr) fflush(stderr) exit(1) } // Force CoreGraphics Services initialization on the main thread. // Without this, SCContentFilter(desktopIndependentWindow:) crashes with // CGS_REQUIRE_INIT because CGS is never initialised in a CLI tool. let _ = CGMainDisplayID() // Pre-flight check: ensure screen recording permission is granted before // attempting capture. On macOS 15+, a one-session grant may expire after the // parent app restarts. CGRequestScreenCaptureAccess() will trigger the // system-level permission dialog (or open System Settings) when not yet granted. if !CGPreflightScreenCaptureAccess() { let granted = CGRequestScreenCaptureAccess() if !granted { fputs("SCREEN_RECORDING_PERMISSION_DENIED\n", stderr) fflush(stderr) exit(1) } } // Pre-flight check for microphone access when mic capture is requested. if let configData = CommandLine.arguments[1].data(using: .utf8), let config = try? JSONDecoder().decode(CaptureConfig.self, from: configData), config.capturesMicrophone == true { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: break case .notDetermined: let sem = DispatchSemaphore(value: 0) AVCaptureDevice.requestAccess(for: .audio) { _ in sem.signal() } sem.wait() if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) fflush(stderr) exit(1) } default: fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) fflush(stderr) exit(1) } } let service = RecorderService() service.start(configJSON: CommandLine.arguments[1]) DispatchQueue.global(qos: .utility).async { while let input = readLine(strippingNewline: true)?.lowercased() { if input == "pause" { service.pause() continue } if input == "resume" { service.resume() continue } if input == "stop" { service.stop() break } } } service.waitUntilFinished()