oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1#if os(macOS)
2 import CallsBroadcastKit
3 import CoreMedia
4 import Foundation
5 import ScreenCaptureKit
6 import LiveKitWebRTC
7 import os
8
9 /// A display or a window, chosen with the system's own picker and
10 /// delivered into the call's screen source.
11 ///
12 /// The picker is SCContentSharingPicker (macOS 14, the app's floor), and
13 /// that choice does more than save drawing one: content a person picks
14 /// there is content they have consented to share, so the app needs no
15 /// Screen Recording permission of its own and never sends anyone to
16 /// System Settings to grant one. There is nothing in Info.plist for it
17 /// either — macOS has no usage string for screen capture. The same holds
18 /// in the App Sandbox, which is why this is also the path a sandboxed
19 /// build can keep.
20 ///
21 /// The capture ends on its own when the person stops it from the menu
22 /// bar's sharing indicator, or the shared window closes; either one
23 /// reaches `ended`, which the call treats exactly like its own button.
24 ///
25 /// It can also be started again on the same content (`restart`), which
26 /// is what the screen watchdog asks for when the capture stops reporting
27 /// anything at all (see ScreenWatchdog), and what a capture error other
28 /// than the person stopping it gets before the share is given up.
29 @MainActor public final class MacScreenCapture: NSObject, ScreenCapture {
30 private var stream: SCStream?
31 private var frames: ScreenFrames?
32 private var filter: SCContentFilter?
33 private var source: RTCVideoSource?
34 private var ended: (@MainActor () -> Void)?
35 private var choosing: CheckedContinuation<SCContentFilter, Error>?
36 private let queue = DispatchQueue(label: "calls.screen", qos: .userInteractive)
37 // Counts from the captures this one has replaced, so the totals the
38 // watchdog reads run on across a restart.
39 private var earlier = ScreenCaptureCounts(heartbeats: 0, delivered: 0)
40 // Restarts after a capture error, for `didStopWithError`'s budget.
41 private var errorRestarts: [Date] = []
42 private var restarting = false
43
44 public override init() {}
45
46 public var counts: ScreenCaptureCounts {
47 let now = frames?.counts ?? ScreenCaptureCounts(heartbeats: 0, delivered: 0)
48 return ScreenCaptureCounts(
49 heartbeats: (earlier.heartbeats ?? 0) + (now.heartbeats ?? 0),
50 delivered: earlier.delivered + now.delivered)
51 }
52
53 public func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws {
54 let picker = SCContentSharingPicker.shared
55 var configuration = SCContentSharingPickerConfiguration()
56 configuration.allowedPickerModes = [.singleDisplay, .singleWindow]
57 // The call's own window is the one thing nobody in it needs to see:
58 // shared, it is a hall of mirrors of their own faces.
59 if let own = Bundle.main.bundleIdentifier {
60 configuration.excludedBundleIDs = [own]
61 }
62 picker.defaultConfiguration = configuration
63 picker.maximumStreamCount = 1
64 picker.add(self)
65 picker.isActive = true
66 let filter: SCContentFilter
67 do {
68 let picked = try await withCheckedThrowingContinuation { continuation in
69 choosing = continuation
70 picker.present()
71 }
72 filter = await Self.withoutCalls(picked)
73 } catch {
74 release()
75 throw error
76 }
77 do {
78 try await begin(filter: filter, source: source)
79 } catch {
80 release()
81 throw error
82 }
83 self.ended = ended
84 }
85
86 /// A given display, with no picker: the interop gate's real capture,
87 /// which is the only way to put ScreenCaptureKit and the hardware
88 /// encoder under test together. Needs Screen Recording permission for
89 /// the process, which the picker path never does.
90 @_spi(Fixtures) public func start(
91 display: SCDisplay, source: RTCVideoSource, ended: @escaping @MainActor () -> Void
92 ) async throws {
93 try await begin(filter: SCContentFilter(display: display, excludingWindows: []), source: source)
94 self.ended = ended
95 }
96
97 private func begin(filter: SCContentFilter, source: RTCVideoSource) async throws {
98 let frames = ScreenFrames(source: source, queue: queue)
99 let stream = SCStream(filter: filter, configuration: Self.configuration(for: filter), delegate: self)
100 do {
101 try stream.addStreamOutput(frames, type: .screen, sampleHandlerQueue: queue)
102 try await stream.startCapture()
103 } catch {
104 frames.stop()
105 throw error
106 }
107 self.stream = stream
108 self.frames = frames
109 self.filter = filter
110 self.source = source
111 let configured = Self.configuration(for: filter)
112 frameSize = (configured.width, configured.height)
113 }
114 public private(set) var frameSize: (width: Int, height: Int)?
115
116 /// The same content into the same source, on a capture of its own:
117 /// a fresh stream with a fresh pool of surfaces. What stopping and
118 /// sharing again did by hand, without the picker and without the
119 /// call hearing about it — the transceivers and tracks stay as they
120 /// are, and only the frames behind them are new.
121 public func restart() async -> Bool {
122 guard !restarting, let filter, let source, let old = stream else { return false }
123 restarting = true
124 defer { restarting = false }
125 earlier = counts
126 stream = nil
127 frames?.stop()
128 frames = nil
129 try? await old.stopCapture()
130 guard filter === self.filter, self.source === source else { return false }
131 do {
132 try await begin(filter: filter, source: source)
133 ScreenLog.info("capture restarted")
134 return true
135 } catch {
136 ScreenLog.error("capture restart failed: \(Self.describe(error))")
137 return false
138 }
139 }
140
141 public func stop() {
142 ended = nil
143 let stream = self.stream
144 self.stream = nil
145 frames?.stop()
146 frames = nil
147 filter = nil
148 source = nil
149 release()
150 stream?.stopCapture { _ in }
151 }
152
153 private func release() {
154 let picker = SCContentSharingPicker.shared
155 picker.remove(self)
156 picker.isActive = false
157 choosing?.resume(throwing: CancellationError())
158 choosing = nil
159 }
160
161 /// The capture ending under the call, rather than by it.
162 private func finished() {
163 let ended = self.ended
164 stop()
165 ended?()
166 }
167
168 /// The picked content with Calls taken out of it.
169 ///
170 /// A whole display, as the picker hands it back, includes this app's
171 /// own windows: the call window, and the small floating one it shows
172 /// while it is in the background. Shared, they are a hall of mirrors
173 /// of everyone's faces — and the floating one is always on top of
174 /// whatever the person meant to show. So a display is rebuilt as the
175 /// same display excluding this application, which also covers windows
176 /// that open after sharing starts. A single window needs nothing: this
177 /// app's are left out of the picker, so the window is someone else's.
178 ///
179 /// The display comes from the filter itself on macOS 15.2 and later,
180 /// and before that from matching its frame. The application comes from
181 /// the current process's own shareable content, which is the one query
182 /// that asks for no Screen Recording permission. On a macOS too old
183 /// for that (before 14.4) the display is shared as picked; the
184 /// floating window's own `sharingType` is then the only thing keeping
185 /// it out, and the call window is in the share.
186 static func withoutCalls(_ filter: SCContentFilter) async -> SCContentFilter {
187 guard filter.style == .display, #available(macOS 14.4, *),
188 let content = try? await SCShareableContent.currentProcess,
189 let calls = content.applications.first(where: { $0.processID == getpid() })
190 else { return filter }
191 var display: SCDisplay?
192 if #available(macOS 15.2, *) { display = filter.includedDisplays.first }
193 if display == nil { display = content.displays.first { $0.frame == filter.contentRect } }
194 guard let display else { return filter }
195 return SCContentFilter(display: display, excludingApplications: [calls], exceptingWindows: [])
196 }
197
198 /// Full resolution up to 1920 on the long side, at up to 30 frames a
199 /// second. Larger is sharper than any receiver will be shown it and
200 /// costs the encoder for nothing. What gives way under pressure is the
201 /// "Optimise for" mode's (ScreenSendPolicy).
202 ///
203 /// Six surfaces in the queue. The queue depth is not a queue of frames
204 /// waiting to be sent — a late frame is dropped on arrival (see
205 /// ScreenFrames) — it is the pool of surfaces the capture draws into,
206 /// and every surface the encoder is still holding, or this capture is
207 /// keeping for its repeat, is one the capture cannot use. When they
208 /// are all held the capture stops outright: no frames and no idle
209 /// reports either, until one comes back. Measured on 2026-09-28 at a
210 /// depth of three, holding three surfaces stopped a display capture
211 /// after three frames, and holding two did not. 0.4.0 went from five
212 /// to three and began holding one for the repeat, which left the
213 /// hardware encoder two frames in flight before the screen froze
214 /// (Paul, 2026-09-28: "after a while, my screen froze on her end").
215 /// The depth above. Settable only for the interop gate, which measures
216 /// the depth 0.4.1 shipped with against this one.
217 @_spi(Fixtures) public static var queueDepth = 6
218
219 static func configuration(for filter: SCContentFilter) -> SCStreamConfiguration {
220 let configuration = SCStreamConfiguration()
221 let rect = filter.contentRect
222 let scale = CGFloat(filter.pointPixelScale)
223 let size = fitted(width: rect.width * scale, height: rect.height * scale, longest: 1920)
224 configuration.width = size.width
225 configuration.height = size.height
226 configuration.minimumFrameInterval = CMTime(value: 1, timescale: 30)
227 configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
228 configuration.showsCursor = true
229 configuration.queueDepth = queueDepth
230 return configuration
231 }
232 }
233
234 extension MacScreenCapture: SCContentSharingPickerObserver {
235 nonisolated public func contentSharingPicker(
236 _ picker: SCContentSharingPicker, didCancelFor stream: SCStream?
237 ) {
238 Task { @MainActor [weak self] in
239 guard let self else { return }
240 if stream == nil || stream === self.stream {
241 if let choosing = self.choosing {
242 self.choosing = nil
243 choosing.resume(throwing: ScreenShareRefused())
244 }
245 }
246 }
247 }
248
249 nonisolated public func contentSharingPicker(
250 _ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?
251 ) {
252 Task { @MainActor [weak self] in
253 guard let self else { return }
254 if let choosing = self.choosing {
255 self.choosing = nil
256 choosing.resume(returning: filter)
257 return
258 }
259 // Chosen again from the menu bar mid-share: the same stream, now
260 // on something else. The call does not need to hear about it.
261 guard let current = self.stream, stream == nil || stream === current else { return }
262 let next = await Self.withoutCalls(filter)
263 guard current === self.stream else { return }
264 self.filter = next
265 current.updateContentFilter(next) { _ in }
266 current.updateConfiguration(Self.configuration(for: next)) { _ in }
267 }
268 }
269
270 nonisolated public func contentSharingPickerStartDidFailWithError(_ error: Error) {
271 Task { @MainActor [weak self] in
272 guard let self, let choosing = self.choosing else { return }
273 self.choosing = nil
274 choosing.resume(throwing: error)
275 }
276 }
277 }
278
279 extension MacScreenCapture: SCStreamDelegate {
280 /// The person stopping the share from the menu bar, or the shared
281 /// window or display going away, ends it: that is what they asked for,
282 /// or all there is. Anything else — the capture service interrupted,
283 /// the system stopping the stream across a sleep or a display change —
284 /// is started again on the same content, up to three times a minute,
285 /// before the share is given up. Up to 0.4.1 every error ended the
286 /// share, and a person whose capture had merely hiccuped was left to
287 /// notice and share again.
288 nonisolated public func stream(_ stream: SCStream, didStopWithError error: Error) {
289 Task { @MainActor [weak self] in
290 guard let self, stream === self.stream else { return }
291 ScreenLog.info("capture stopped: \(Self.describe(error))")
292 let now = Date()
293 self.errorRestarts.removeAll { now.timeIntervalSince($0) > 60 }
294 guard Self.recoverable(error), self.errorRestarts.count < 3 else {
295 self.finished()
296 return
297 }
298 self.errorRestarts.append(now)
299 if await !self.restart() { self.finished() }
300 }
301 }
302
303 /// Whether a capture error is worth starting again after.
304 nonisolated static func recoverable(_ error: Error) -> Bool {
305 let error = error as NSError
306 guard error.domain == SCStreamErrorDomain else { return true }
307 switch SCStreamError.Code(rawValue: error.code) {
308 case .userStopped?, .noCaptureSource?, .noWindowList?, .noDisplayList?, .userDeclined?,
309 .missingEntitlements?:
310 return false
311 default:
312 return true
313 }
314 }
315
316 nonisolated static func describe(_ error: Error) -> String {
317 let error = error as NSError
318 return "\(error.domain) \(error.code)"
319 }
320 }
321
322 struct ScreenShareRefused: Error {}
323
324 /// Hands each complete frame to the source. Called on the capture's own
325 /// queue, never the main actor: a screen is thirty frames a second and
326 /// the main actor has an interface to draw.
327 ///
328 /// Two things beyond passing frames on:
329 ///
330 /// - A frame that reaches here more than a tenth of a second after it
331 /// was captured is dropped: the queue is behind, and a newer frame is
332 /// already waiting. Sending it would only add its lateness to the
333 /// picture everyone is watching.
334 /// - An idle screen sends nothing (ScreenCaptureKit reports only
335 /// changes), and the encoder only makes a keyframe when a frame comes
336 /// to encode. So a receiver that lost a packet on a still screen, or
337 /// joined during one, could wait on a broken or empty picture until
338 /// something moved. The last frame is sent again once a second while
339 /// nothing changes, which gives the encoder its chance.
340 private final class ScreenFrames: NSObject, SCStreamOutput, @unchecked Sendable {
341 private let source: RTCVideoSource
342 private let capturer: RTCVideoCapturer
343 private let queue: DispatchQueue
344 private var timer: DispatchSourceTimer?
345 // Only touched on `queue`. Holding the newest frame pins one of the
346 // capture's surfaces, but it is the one the encoder was just given
347 // and is usually still holding anyway.
348 private var last: CVPixelBuffer?
349 private var lastSent: UInt64 = 0
350 // Read on the main actor by the watchdog; written on `queue`.
351 private let tally = OSAllocatedUnfairLock(initialState: ScreenCaptureCounts(heartbeats: 0, delivered: 0))
352 var counts: ScreenCaptureCounts { tally.withLock { $0 } }
353
354 static let stale = 0.1
355 static let repeatAfter: UInt64 = 1_000_000_000
356
357 init(source: RTCVideoSource, queue: DispatchQueue) {
358 self.source = source
359 self.queue = queue
360 capturer = RTCVideoCapturer(delegate: source)
361 super.init()
362 let timer = DispatchSource.makeTimerSource(queue: queue)
363 timer.schedule(deadline: .now() + .milliseconds(500), repeating: .milliseconds(500), leeway: .milliseconds(50))
364 timer.setEventHandler { [weak self] in self?.repeatIfIdle() }
365 timer.resume()
366 self.timer = timer
367 }
368
369 func stop() {
370 timer?.cancel()
371 timer = nil
372 queue.async { self.last = nil }
373 }
374
375 func stream(
376 _ stream: SCStream, didOutputSampleBuffer sample: CMSampleBuffer, of type: SCStreamOutputType
377 ) {
378 guard type == .screen, sample.isValid else { return }
379 // Anything at all, idle reports included, is the capture alive.
380 tally.withLock { $0.heartbeats = ($0.heartbeats ?? 0) + 1 }
381 guard let pixels = sample.imageBuffer else { return }
382 // Only complete frames carry pixels. An idle screen sends status
383 // frames and nothing else; the repeat below covers it.
384 guard
385 let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false)
386 as? [[SCStreamFrameInfo: Any]],
387 let raw = attachments.first?[.status] as? Int,
388 SCFrameStatus(rawValue: raw) == .complete
389 else { return }
390 let captured = CMTimeGetSeconds(sample.presentationTimeStamp)
391 let now = CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock()))
392 last = pixels
393 if now - captured > Self.stale { return }
394 deliver(pixels, at: captured)
395 }
396
397 private func repeatIfIdle() {
398 guard let last else { return }
399 let now = CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock()))
400 let ns = UInt64(max(0, now) * 1_000_000_000)
401 guard ns >= lastSent, ns - lastSent >= Self.repeatAfter else { return }
402 deliver(last, at: now)
403 }
404
405 private func deliver(_ pixels: CVPixelBuffer, at seconds: Double) {
406 let ns = UInt64(max(0, seconds) * 1_000_000_000)
407 // Never backwards: a repeat stamped just after a real frame would
408 // otherwise be followed by an older one.
409 guard ns > lastSent else { return }
410 lastSent = ns
411 tally.withLock { $0.delivered += 1 }
412 source.capturer(
413 capturer,
414 didCapture: RTCVideoFrame(
415 buffer: RTCCVPixelBuffer(pixelBuffer: pixels), rotation: ._0, timeStampNs: Int64(ns)))
416 }
417 }
418#endif
419