#if os(macOS) import CallsBroadcastKit import CoreMedia import Foundation import ScreenCaptureKit import LiveKitWebRTC import os /// A display or a window, chosen with the system's own picker and /// delivered into the call's screen source. /// /// The picker is SCContentSharingPicker (macOS 14, the app's floor), and /// that choice does more than save drawing one: content a person picks /// there is content they have consented to share, so the app needs no /// Screen Recording permission of its own and never sends anyone to /// System Settings to grant one. There is nothing in Info.plist for it /// either — macOS has no usage string for screen capture. The same holds /// in the App Sandbox, which is why this is also the path a sandboxed /// build can keep. /// /// The capture ends on its own when the person stops it from the menu /// bar's sharing indicator, or the shared window closes; either one /// reaches `ended`, which the call treats exactly like its own button. /// /// It can also be started again on the same content (`restart`), which /// is what the screen watchdog asks for when the capture stops reporting /// anything at all (see ScreenWatchdog), and what a capture error other /// than the person stopping it gets before the share is given up. @MainActor public final class MacScreenCapture: NSObject, ScreenCapture { private var stream: SCStream? private var frames: ScreenFrames? private var filter: SCContentFilter? private var source: RTCVideoSource? private var ended: (@MainActor () -> Void)? private var choosing: CheckedContinuation? private let queue = DispatchQueue(label: "calls.screen", qos: .userInteractive) // Counts from the captures this one has replaced, so the totals the // watchdog reads run on across a restart. private var earlier = ScreenCaptureCounts(heartbeats: 0, delivered: 0) // Restarts after a capture error, for `didStopWithError`'s budget. private var errorRestarts: [Date] = [] private var restarting = false public override init() {} public var counts: ScreenCaptureCounts { let now = frames?.counts ?? ScreenCaptureCounts(heartbeats: 0, delivered: 0) return ScreenCaptureCounts( heartbeats: (earlier.heartbeats ?? 0) + (now.heartbeats ?? 0), delivered: earlier.delivered + now.delivered) } public func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws { let picker = SCContentSharingPicker.shared var configuration = SCContentSharingPickerConfiguration() configuration.allowedPickerModes = [.singleDisplay, .singleWindow] // The call's own window is the one thing nobody in it needs to see: // shared, it is a hall of mirrors of their own faces. if let own = Bundle.main.bundleIdentifier { configuration.excludedBundleIDs = [own] } picker.defaultConfiguration = configuration picker.maximumStreamCount = 1 picker.add(self) picker.isActive = true let filter: SCContentFilter do { let picked = try await withCheckedThrowingContinuation { continuation in choosing = continuation picker.present() } filter = await Self.withoutCalls(picked) } catch { release() throw error } do { try await begin(filter: filter, source: source) } catch { release() throw error } self.ended = ended } /// A given display, with no picker: the interop gate's real capture, /// which is the only way to put ScreenCaptureKit and the hardware /// encoder under test together. Needs Screen Recording permission for /// the process, which the picker path never does. @_spi(Fixtures) public func start( display: SCDisplay, source: RTCVideoSource, ended: @escaping @MainActor () -> Void ) async throws { try await begin(filter: SCContentFilter(display: display, excludingWindows: []), source: source) self.ended = ended } private func begin(filter: SCContentFilter, source: RTCVideoSource) async throws { let frames = ScreenFrames(source: source, queue: queue) let stream = SCStream(filter: filter, configuration: Self.configuration(for: filter), delegate: self) do { try stream.addStreamOutput(frames, type: .screen, sampleHandlerQueue: queue) try await stream.startCapture() } catch { frames.stop() throw error } self.stream = stream self.frames = frames self.filter = filter self.source = source let configured = Self.configuration(for: filter) frameSize = (configured.width, configured.height) } public private(set) var frameSize: (width: Int, height: Int)? /// The same content into the same source, on a capture of its own: /// a fresh stream with a fresh pool of surfaces. What stopping and /// sharing again did by hand, without the picker and without the /// call hearing about it — the transceivers and tracks stay as they /// are, and only the frames behind them are new. public func restart() async -> Bool { guard !restarting, let filter, let source, let old = stream else { return false } restarting = true defer { restarting = false } earlier = counts stream = nil frames?.stop() frames = nil try? await old.stopCapture() guard filter === self.filter, self.source === source else { return false } do { try await begin(filter: filter, source: source) ScreenLog.info("capture restarted") return true } catch { ScreenLog.error("capture restart failed: \(Self.describe(error))") return false } } public func stop() { ended = nil let stream = self.stream self.stream = nil frames?.stop() frames = nil filter = nil source = nil release() stream?.stopCapture { _ in } } private func release() { let picker = SCContentSharingPicker.shared picker.remove(self) picker.isActive = false choosing?.resume(throwing: CancellationError()) choosing = nil } /// The capture ending under the call, rather than by it. private func finished() { let ended = self.ended stop() ended?() } /// The picked content with Calls taken out of it. /// /// A whole display, as the picker hands it back, includes this app's /// own windows: the call window, and the small floating one it shows /// while it is in the background. Shared, they are a hall of mirrors /// of everyone's faces — and the floating one is always on top of /// whatever the person meant to show. So a display is rebuilt as the /// same display excluding this application, which also covers windows /// that open after sharing starts. A single window needs nothing: this /// app's are left out of the picker, so the window is someone else's. /// /// The display comes from the filter itself on macOS 15.2 and later, /// and before that from matching its frame. The application comes from /// the current process's own shareable content, which is the one query /// that asks for no Screen Recording permission. On a macOS too old /// for that (before 14.4) the display is shared as picked; the /// floating window's own `sharingType` is then the only thing keeping /// it out, and the call window is in the share. static func withoutCalls(_ filter: SCContentFilter) async -> SCContentFilter { guard filter.style == .display, #available(macOS 14.4, *), let content = try? await SCShareableContent.currentProcess, let calls = content.applications.first(where: { $0.processID == getpid() }) else { return filter } var display: SCDisplay? if #available(macOS 15.2, *) { display = filter.includedDisplays.first } if display == nil { display = content.displays.first { $0.frame == filter.contentRect } } guard let display else { return filter } return SCContentFilter(display: display, excludingApplications: [calls], exceptingWindows: []) } /// Full resolution up to 1920 on the long side, at up to 30 frames a /// second. Larger is sharper than any receiver will be shown it and /// costs the encoder for nothing. What gives way under pressure is the /// "Optimise for" mode's (ScreenSendPolicy). /// /// Six surfaces in the queue. The queue depth is not a queue of frames /// waiting to be sent — a late frame is dropped on arrival (see /// ScreenFrames) — it is the pool of surfaces the capture draws into, /// and every surface the encoder is still holding, or this capture is /// keeping for its repeat, is one the capture cannot use. When they /// are all held the capture stops outright: no frames and no idle /// reports either, until one comes back. Measured on 2026-09-28 at a /// depth of three, holding three surfaces stopped a display capture /// after three frames, and holding two did not. 0.4.0 went from five /// to three and began holding one for the repeat, which left the /// hardware encoder two frames in flight before the screen froze /// (Paul, 2026-09-28: "after a while, my screen froze on her end"). /// The depth above. Settable only for the interop gate, which measures /// the depth 0.4.1 shipped with against this one. @_spi(Fixtures) public static var queueDepth = 6 static func configuration(for filter: SCContentFilter) -> SCStreamConfiguration { let configuration = SCStreamConfiguration() let rect = filter.contentRect let scale = CGFloat(filter.pointPixelScale) let size = fitted(width: rect.width * scale, height: rect.height * scale, longest: 1920) configuration.width = size.width configuration.height = size.height configuration.minimumFrameInterval = CMTime(value: 1, timescale: 30) configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarFullRange configuration.showsCursor = true configuration.queueDepth = queueDepth return configuration } } extension MacScreenCapture: SCContentSharingPickerObserver { nonisolated public func contentSharingPicker( _ picker: SCContentSharingPicker, didCancelFor stream: SCStream? ) { Task { @MainActor [weak self] in guard let self else { return } if stream == nil || stream === self.stream { if let choosing = self.choosing { self.choosing = nil choosing.resume(throwing: ScreenShareRefused()) } } } } nonisolated public func contentSharingPicker( _ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream? ) { Task { @MainActor [weak self] in guard let self else { return } if let choosing = self.choosing { self.choosing = nil choosing.resume(returning: filter) return } // Chosen again from the menu bar mid-share: the same stream, now // on something else. The call does not need to hear about it. guard let current = self.stream, stream == nil || stream === current else { return } let next = await Self.withoutCalls(filter) guard current === self.stream else { return } self.filter = next current.updateContentFilter(next) { _ in } current.updateConfiguration(Self.configuration(for: next)) { _ in } } } nonisolated public func contentSharingPickerStartDidFailWithError(_ error: Error) { Task { @MainActor [weak self] in guard let self, let choosing = self.choosing else { return } self.choosing = nil choosing.resume(throwing: error) } } } extension MacScreenCapture: SCStreamDelegate { /// The person stopping the share from the menu bar, or the shared /// window or display going away, ends it: that is what they asked for, /// or all there is. Anything else — the capture service interrupted, /// the system stopping the stream across a sleep or a display change — /// is started again on the same content, up to three times a minute, /// before the share is given up. Up to 0.4.1 every error ended the /// share, and a person whose capture had merely hiccuped was left to /// notice and share again. nonisolated public func stream(_ stream: SCStream, didStopWithError error: Error) { Task { @MainActor [weak self] in guard let self, stream === self.stream else { return } ScreenLog.info("capture stopped: \(Self.describe(error))") let now = Date() self.errorRestarts.removeAll { now.timeIntervalSince($0) > 60 } guard Self.recoverable(error), self.errorRestarts.count < 3 else { self.finished() return } self.errorRestarts.append(now) if await !self.restart() { self.finished() } } } /// Whether a capture error is worth starting again after. nonisolated static func recoverable(_ error: Error) -> Bool { let error = error as NSError guard error.domain == SCStreamErrorDomain else { return true } switch SCStreamError.Code(rawValue: error.code) { case .userStopped?, .noCaptureSource?, .noWindowList?, .noDisplayList?, .userDeclined?, .missingEntitlements?: return false default: return true } } nonisolated static func describe(_ error: Error) -> String { let error = error as NSError return "\(error.domain) \(error.code)" } } struct ScreenShareRefused: Error {} /// Hands each complete frame to the source. Called on the capture's own /// queue, never the main actor: a screen is thirty frames a second and /// the main actor has an interface to draw. /// /// Two things beyond passing frames on: /// /// - A frame that reaches here more than a tenth of a second after it /// was captured is dropped: the queue is behind, and a newer frame is /// already waiting. Sending it would only add its lateness to the /// picture everyone is watching. /// - An idle screen sends nothing (ScreenCaptureKit reports only /// changes), and the encoder only makes a keyframe when a frame comes /// to encode. So a receiver that lost a packet on a still screen, or /// joined during one, could wait on a broken or empty picture until /// something moved. The last frame is sent again once a second while /// nothing changes, which gives the encoder its chance. private final class ScreenFrames: NSObject, SCStreamOutput, @unchecked Sendable { private let source: RTCVideoSource private let capturer: RTCVideoCapturer private let queue: DispatchQueue private var timer: DispatchSourceTimer? // Only touched on `queue`. Holding the newest frame pins one of the // capture's surfaces, but it is the one the encoder was just given // and is usually still holding anyway. private var last: CVPixelBuffer? private var lastSent: UInt64 = 0 // Read on the main actor by the watchdog; written on `queue`. private let tally = OSAllocatedUnfairLock(initialState: ScreenCaptureCounts(heartbeats: 0, delivered: 0)) var counts: ScreenCaptureCounts { tally.withLock { $0 } } static let stale = 0.1 static let repeatAfter: UInt64 = 1_000_000_000 init(source: RTCVideoSource, queue: DispatchQueue) { self.source = source self.queue = queue capturer = RTCVideoCapturer(delegate: source) super.init() let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + .milliseconds(500), repeating: .milliseconds(500), leeway: .milliseconds(50)) timer.setEventHandler { [weak self] in self?.repeatIfIdle() } timer.resume() self.timer = timer } func stop() { timer?.cancel() timer = nil queue.async { self.last = nil } } func stream( _ stream: SCStream, didOutputSampleBuffer sample: CMSampleBuffer, of type: SCStreamOutputType ) { guard type == .screen, sample.isValid else { return } // Anything at all, idle reports included, is the capture alive. tally.withLock { $0.heartbeats = ($0.heartbeats ?? 0) + 1 } guard let pixels = sample.imageBuffer else { return } // Only complete frames carry pixels. An idle screen sends status // frames and nothing else; the repeat below covers it. guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]], let raw = attachments.first?[.status] as? Int, SCFrameStatus(rawValue: raw) == .complete else { return } let captured = CMTimeGetSeconds(sample.presentationTimeStamp) let now = CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock())) last = pixels if now - captured > Self.stale { return } deliver(pixels, at: captured) } private func repeatIfIdle() { guard let last else { return } let now = CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock())) let ns = UInt64(max(0, now) * 1_000_000_000) guard ns >= lastSent, ns - lastSent >= Self.repeatAfter else { return } deliver(last, at: now) } private func deliver(_ pixels: CVPixelBuffer, at seconds: Double) { let ns = UInt64(max(0, seconds) * 1_000_000_000) // Never backwards: a repeat stamped just after a real frame would // otherwise be followed by an older one. guard ns > lastSent else { return } lastSent = ns tally.withLock { $0.delivered += 1 } source.capturer( capturer, didCapture: RTCVideoFrame( buffer: RTCCVPixelBuffer(pixelBuffer: pixels), rotation: ._0, timeStampNs: Int64(ns))) } } #endif