New macOS screen-capturer which uses ScreenCaptureKit.
This supports:
* Full-screen capture from any display, via SelectSource().
* Changing the display, via SelectSource(), while capture is running.
* Handling screen-resolution changes while capture is running.
* Capturing from high-DPI displays at their native resolution.
* Basic damage-tracking: the frame's updated-region is either set to
empty, or the full frame area.
It currently does not support:
* Window capture.
* Excluded windows.
* Full-desktop capture across all displays.
* More detailed damage-tracking.
The capturer is not yet enabled. Followup CLs will add a
DesktopCaptureOption to enable this capturer on supported versions of
macOS.
Bug: chromium:327458809
Change-Id: Ie619f6c6c1d6edf0fb9320d4fece578754a732dc
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/352544
Reviewed-by: Johannes Kron <kron@webrtc.org>
Reviewed-by: Alexander Cooper <alcooper@chromium.org>
Commit-Queue: Lambros Lambrou <lambroslambrou@chromium.org>
Cr-Commit-Position: refs/heads/main@{#42510}
diff --git a/modules/desktop_capture/BUILD.gn b/modules/desktop_capture/BUILD.gn
index dcde2c6..90930ad 100644
--- a/modules/desktop_capture/BUILD.gn
+++ b/modules/desktop_capture/BUILD.gn
@@ -628,6 +628,8 @@
"mac/desktop_frame_provider.mm",
"mac/screen_capturer_mac.h",
"mac/screen_capturer_mac.mm",
+ "mac/screen_capturer_sck.h",
+ "mac/screen_capturer_sck.mm",
"mac/window_list_utils.h",
"mouse_cursor.h",
"mouse_cursor_monitor.h",
@@ -657,9 +659,11 @@
]
frameworks = [
"AppKit.framework",
+ "CoreVideo.framework",
"IOKit.framework",
"IOSurface.framework",
]
+ weak_frameworks = [ "ScreenCaptureKit.framework" ] # macOS 12.3
}
}
diff --git a/modules/desktop_capture/DEPS b/modules/desktop_capture/DEPS
index 8c894c4..02bdbe1 100644
--- a/modules/desktop_capture/DEPS
+++ b/modules/desktop_capture/DEPS
@@ -16,4 +16,7 @@
"screen_capturer_mac\.mm": [
"+sdk/objc",
],
+ "screen_capturer_sck\.mm": [
+ "+sdk/objc",
+ ],
}
diff --git a/modules/desktop_capture/mac/screen_capturer_sck.h b/modules/desktop_capture/mac/screen_capturer_sck.h
new file mode 100644
index 0000000..2c5241d
--- /dev/null
+++ b/modules/desktop_capture/mac/screen_capturer_sck.h
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2024 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#ifndef MODULES_DESKTOP_CAPTURE_MAC_SCREEN_CAPTURER_SCK_H_
+#define MODULES_DESKTOP_CAPTURE_MAC_SCREEN_CAPTURER_SCK_H_
+
+#include <memory>
+
+#include "modules/desktop_capture/desktop_capture_options.h"
+#include "modules/desktop_capture/desktop_capturer.h"
+
+namespace webrtc {
+
+// A DesktopCapturer implementation that uses ScreenCaptureKit.
+std::unique_ptr<DesktopCapturer> CreateScreenCapturerSck(
+ const DesktopCaptureOptions& options);
+
+} // namespace webrtc
+
+#endif // MODULES_DESKTOP_CAPTURE_MAC_SCREEN_CAPTURER_SCK_H_
diff --git a/modules/desktop_capture/mac/screen_capturer_sck.mm b/modules/desktop_capture/mac/screen_capturer_sck.mm
new file mode 100644
index 0000000..2135fae
--- /dev/null
+++ b/modules/desktop_capture/mac/screen_capturer_sck.mm
@@ -0,0 +1,409 @@
+/*
+ * Copyright (c) 2024 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#include "modules/desktop_capture/mac/screen_capturer_sck.h"
+
+#import <ScreenCaptureKit/ScreenCaptureKit.h>
+
+#include <atomic>
+
+#include "modules/desktop_capture/mac/desktop_frame_iosurface.h"
+#include "modules/desktop_capture/shared_desktop_frame.h"
+#include "rtc_base/logging.h"
+#include "rtc_base/synchronization/mutex.h"
+#include "rtc_base/thread_annotations.h"
+#include "sdk/objc/helpers/scoped_cftyperef.h"
+
+using webrtc::DesktopFrameIOSurface;
+
+namespace webrtc {
+class ScreenCapturerSck;
+} // namespace webrtc
+
+// The ScreenCaptureKit API was available in macOS 12.3, but full-screen capture was reported to be
+// broken before macOS 13 - see http://crbug.com/40234870.
+API_AVAILABLE(macos(13.0))
+@interface SckHelper : NSObject <SCStreamDelegate, SCStreamOutput>
+
+- (instancetype)initWithCapturer:(webrtc::ScreenCapturerSck*)capturer;
+
+- (void)onShareableContentCreated:(SCShareableContent*)content;
+
+// Called just before the capturer is destroyed. This avoids a dangling pointer, and prevents any
+// new calls into a deleted capturer. If any method-call on the capturer is currently running on a
+// different thread, this blocks until it completes.
+- (void)releaseCapturer;
+
+@end
+
+namespace webrtc {
+
+class API_AVAILABLE(macos(13.0)) ScreenCapturerSck final : public DesktopCapturer {
+ public:
+ explicit ScreenCapturerSck(const DesktopCaptureOptions& options);
+
+ ScreenCapturerSck(const ScreenCapturerSck&) = delete;
+ ScreenCapturerSck& operator=(const ScreenCapturerSck&) = delete;
+
+ ~ScreenCapturerSck() override;
+
+ // DesktopCapturer interface. All these methods run on the caller's thread.
+ void Start(DesktopCapturer::Callback* callback) override;
+ void SetMaxFrameRate(uint32_t max_frame_rate) override;
+ void CaptureFrame() override;
+ bool SelectSource(SourceId id) override;
+
+ // Called by SckHelper when shareable content is returned by ScreenCaptureKit. `content` will be
+ // nil if an error occurred. May run on an arbitrary thread.
+ void OnShareableContentCreated(SCShareableContent* content);
+
+ // Called by SckHelper to notify of a newly captured frame. May run on an arbitrary thread.
+ void OnNewIOSurface(IOSurfaceRef io_surface, CFDictionaryRef attachment);
+
+ private:
+ // Called when starting the capturer or the configuration has changed (either from a
+ // SelectSource() call, or the screen-resolution has changed). This tells SCK to fetch new
+ // shareable content, and the completion-handler will either start a new stream, or reconfigure
+ // the existing stream. Runs on the caller's thread.
+ void StartOrReconfigureCapturer();
+
+ // Helper object to receive Objective-C callbacks from ScreenCaptureKit and call into this C++
+ // object. The helper may outlive this C++ instance, if a completion-handler is passed to
+ // ScreenCaptureKit APIs and the C++ object is deleted before the handler executes.
+ SckHelper* __strong helper_;
+
+ // Callback for returning captured frames, or errors, to the caller. Only used on the caller's
+ // thread.
+ Callback* callback_ = nullptr;
+
+ // Options passed to the constructor. May be accessed on any thread, but the options are
+ // unchanged during the capturer's lifetime.
+ DesktopCaptureOptions capture_options_;
+
+ // Signals that a permanent error occurred. This may be set on any thread, and is read by
+ // CaptureFrame() which runs on the caller's thread.
+ std::atomic<bool> permanent_error_ = false;
+
+ // Guards some variables that may be accessed on different threads.
+ Mutex lock_;
+
+ // Provides captured desktop frames.
+ SCStream* __strong stream_ RTC_GUARDED_BY(lock_);
+
+ // Currently selected display, or 0 if the full desktop is selected. This capturer does not
+ // support full-desktop capture, and will fall back to the first display.
+ CGDirectDisplayID current_display_ RTC_GUARDED_BY(lock_) = 0;
+
+ // Used by CaptureFrame() to detect if the screen configuration has changed. Only used on the
+ // caller's thread.
+ MacDesktopConfiguration desktop_config_;
+
+ Mutex latest_frame_lock_;
+ std::unique_ptr<SharedDesktopFrame> latest_frame_ RTC_GUARDED_BY(latest_frame_lock_);
+
+ // Tracks whether the latest frame contains new data since it was returned to the caller. This is
+ // used to set the DesktopFrame's `updated_region` property. The flag is cleared after the frame
+ // is sent to OnCaptureResult(), and is set when SCK reports a new frame with non-empty "dirty"
+ // rectangles.
+ // TODO: crbug.com/327458809 - Replace this flag with ScreenCapturerHelper to more accurately
+ // track the dirty rectangles from the SCStreamFrameInfoDirtyRects attachment.
+ bool frame_is_dirty_ RTC_GUARDED_BY(latest_frame_lock_) = true;
+};
+
+ScreenCapturerSck::ScreenCapturerSck(const DesktopCaptureOptions& options)
+ : capture_options_(options) {
+ helper_ = [[SckHelper alloc] initWithCapturer:this];
+}
+
+ScreenCapturerSck::~ScreenCapturerSck() {
+ [stream_ stopCaptureWithCompletionHandler:nil];
+ [helper_ releaseCapturer];
+}
+
+void ScreenCapturerSck::Start(DesktopCapturer::Callback* callback) {
+ callback_ = callback;
+ desktop_config_ = capture_options_.configuration_monitor()->desktop_configuration();
+ StartOrReconfigureCapturer();
+}
+
+void ScreenCapturerSck::SetMaxFrameRate(uint32_t max_frame_rate) {
+ // TODO: crbug.com/327458809 - Implement this.
+}
+
+void ScreenCapturerSck::CaptureFrame() {
+ if (permanent_error_) {
+ callback_->OnCaptureResult(Result::ERROR_PERMANENT, nullptr);
+ return;
+ }
+
+ MacDesktopConfiguration new_config =
+ capture_options_.configuration_monitor()->desktop_configuration();
+ if (!desktop_config_.Equals(new_config)) {
+ desktop_config_ = new_config;
+ StartOrReconfigureCapturer();
+ }
+
+ std::unique_ptr<DesktopFrame> frame;
+ {
+ MutexLock lock(&latest_frame_lock_);
+ if (latest_frame_) {
+ frame = latest_frame_->Share();
+ if (frame_is_dirty_) {
+ frame->mutable_updated_region()->AddRect(DesktopRect::MakeSize(frame->size()));
+ frame_is_dirty_ = false;
+ }
+ }
+ }
+
+ if (frame) {
+ callback_->OnCaptureResult(Result::SUCCESS, std::move(frame));
+ } else {
+ callback_->OnCaptureResult(Result::ERROR_TEMPORARY, nullptr);
+ }
+}
+
+bool ScreenCapturerSck::SelectSource(SourceId id) {
+ bool stream_started = false;
+ {
+ MutexLock lock(&lock_);
+ current_display_ = id;
+
+ if (stream_) {
+ stream_started = true;
+ }
+ }
+
+ // If the capturer was already started, reconfigure it. Otherwise, wait until Start() gets called.
+ if (stream_started) {
+ StartOrReconfigureCapturer();
+ }
+
+ return true;
+}
+
+void ScreenCapturerSck::OnShareableContentCreated(SCShareableContent* content) {
+ if (!content) {
+ RTC_LOG(LS_ERROR) << "getShareableContent failed.";
+ permanent_error_ = true;
+ return;
+ }
+
+ if (!content.displays.count) {
+ RTC_LOG(LS_ERROR) << "getShareableContent returned no displays.";
+ permanent_error_ = true;
+ return;
+ }
+
+ SCDisplay* captured_display;
+ {
+ MutexLock lock(&lock_);
+ for (SCDisplay* display in content.displays) {
+ if (current_display_ == display.displayID) {
+ captured_display = display;
+ break;
+ }
+ }
+ if (!captured_display) {
+ if (current_display_ == static_cast<CGDirectDisplayID>(kFullDesktopScreenId)) {
+ RTC_LOG(LS_WARNING)
+ << "Full screen capture is not supported, falling back to first display.";
+ } else {
+ RTC_LOG(LS_WARNING) << "Display " << current_display_
+ << " not found, falling back to first display.";
+ }
+ captured_display = content.displays.firstObject;
+ }
+ }
+
+ SCContentFilter* filter = [[SCContentFilter alloc] initWithDisplay:captured_display
+ excludingWindows:@[]];
+ SCStreamConfiguration* config = [[SCStreamConfiguration alloc] init];
+ config.pixelFormat = kCVPixelFormatType_32BGRA;
+ config.showsCursor = capture_options_.prefer_cursor_embedded();
+ config.width = filter.contentRect.size.width * filter.pointPixelScale;
+ config.height = filter.contentRect.size.height * filter.pointPixelScale;
+
+ if (@available(macOS 14.0, *)) {
+ config.captureResolution = SCCaptureResolutionNominal;
+ }
+
+ MutexLock lock(&lock_);
+
+ if (stream_) {
+ RTC_LOG(LS_INFO) << "Updating stream configuration.";
+ [stream_ updateContentFilter:filter completionHandler:nil];
+ [stream_ updateConfiguration:config completionHandler:nil];
+ } else {
+ stream_ = [[SCStream alloc] initWithFilter:filter configuration:config delegate:helper_];
+
+ // TODO: crbug.com/327458809 - Choose an appropriate sampleHandlerQueue for best performance.
+ NSError* add_stream_output_error;
+ bool add_stream_output_result = [stream_ addStreamOutput:helper_
+ type:SCStreamOutputTypeScreen
+ sampleHandlerQueue:nil
+ error:&add_stream_output_error];
+ if (!add_stream_output_result) {
+ stream_ = nil;
+ RTC_LOG(LS_ERROR) << "addStreamOutput failed.";
+ permanent_error_ = true;
+ return;
+ }
+
+ auto handler = ^(NSError* error) {
+ if (error) {
+ // It should be safe to access `this` here, because the C++ destructor calls
+ // stopCaptureWithCompletionHandler on the stream, which cancels this handler.
+ permanent_error_ = true;
+ RTC_LOG(LS_ERROR) << "startCaptureWithCompletionHandler failed.";
+ } else {
+ RTC_LOG(LS_INFO) << "Capture started.";
+ }
+ };
+
+ [stream_ startCaptureWithCompletionHandler:handler];
+ }
+}
+
+void ScreenCapturerSck::OnNewIOSurface(IOSurfaceRef io_surface, CFDictionaryRef attachment) {
+ rtc::ScopedCFTypeRef<IOSurfaceRef> scoped_io_surface(io_surface, rtc::RetainPolicy::RETAIN);
+ std::unique_ptr<DesktopFrameIOSurface> desktop_frame_io_surface =
+ DesktopFrameIOSurface::Wrap(scoped_io_surface);
+ if (!desktop_frame_io_surface) {
+ RTC_LOG(LS_ERROR) << "Failed to lock IOSurface.";
+ return;
+ }
+
+ std::unique_ptr<SharedDesktopFrame> frame =
+ SharedDesktopFrame::Wrap(std::move(desktop_frame_io_surface));
+
+ bool dirty;
+ {
+ MutexLock lock(&latest_frame_lock_);
+ // Mark the frame as dirty if it has a different size, and ignore any DirtyRects attachment in
+ // this case. This is because SCK does not apply a correct attachment to the frame in the case
+ // where the stream was reconfigured.
+ dirty = !latest_frame_ || !latest_frame_->size().equals(frame->size());
+ }
+
+ if (!dirty) {
+ const void* dirty_rects_ptr =
+ CFDictionaryGetValue(attachment, (__bridge CFStringRef)SCStreamFrameInfoDirtyRects);
+ if (!dirty_rects_ptr) {
+ // This is never expected to happen - SCK attaches a non-empty dirty-rects list to every
+ // frame, even when nothing has changed.
+ return;
+ }
+ if (CFGetTypeID(dirty_rects_ptr) != CFArrayGetTypeID()) {
+ return;
+ }
+
+ CFArrayRef dirty_rects_array = static_cast<CFArrayRef>(dirty_rects_ptr);
+ int size = CFArrayGetCount(dirty_rects_array);
+ for (int i = 0; i < size; i++) {
+ const void* rect_ptr = CFArrayGetValueAtIndex(dirty_rects_array, i);
+ if (CFGetTypeID(rect_ptr) != CFDictionaryGetTypeID()) {
+ // This is never expected to happen - the dirty-rects attachment should always be an array
+ // of dictionaries.
+ return;
+ }
+ CGRect rect{};
+ CGRectMakeWithDictionaryRepresentation(static_cast<CFDictionaryRef>(rect_ptr), &rect);
+ if (!CGRectIsEmpty(rect)) {
+ dirty = true;
+ break;
+ }
+ }
+ }
+
+ if (dirty) {
+ MutexLock lock(&latest_frame_lock_);
+ frame_is_dirty_ = true;
+ std::swap(latest_frame_, frame);
+ }
+}
+
+void ScreenCapturerSck::StartOrReconfigureCapturer() {
+ // The copy is needed to avoid capturing `this` in the Objective-C block. Accessing `helper_`
+ // inside the block is equivalent to `this->helper_` and would crash (UAF) if `this` is
+ // deleted before the block is executed.
+ SckHelper* local_helper = helper_;
+ auto handler = ^(SCShareableContent* content, NSError* error) {
+ [local_helper onShareableContentCreated:content];
+ };
+
+ [SCShareableContent getShareableContentWithCompletionHandler:handler];
+}
+
+std::unique_ptr<DesktopCapturer> CreateScreenCapturerSck(const DesktopCaptureOptions& options) {
+ if (@available(macOS 13.0, *)) {
+ return std::make_unique<ScreenCapturerSck>(options);
+ } else {
+ return nullptr;
+ }
+}
+
+} // namespace webrtc
+
+@implementation SckHelper {
+ // This lock is to prevent the capturer being destroyed while an instance method is still running
+ // on another thread.
+ webrtc::Mutex _capturer_lock;
+ webrtc::ScreenCapturerSck* _capturer;
+}
+
+- (instancetype)initWithCapturer:(webrtc::ScreenCapturerSck*)capturer {
+ if (self = [super init]) {
+ _capturer = capturer;
+ }
+ return self;
+}
+
+- (void)onShareableContentCreated:(SCShareableContent*)content {
+ webrtc::MutexLock lock(&_capturer_lock);
+ if (_capturer) {
+ _capturer->OnShareableContentCreated(content);
+ }
+}
+
+- (void)stream:(SCStream*)stream
+ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
+ ofType:(SCStreamOutputType)type {
+ CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
+ if (!pixelBuffer) {
+ return;
+ }
+
+ IOSurfaceRef ioSurface = CVPixelBufferGetIOSurface(pixelBuffer);
+ if (!ioSurface) {
+ return;
+ }
+
+ CFArrayRef attachmentsArray =
+ CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, /*createIfNecessary=*/false);
+ if (!attachmentsArray || CFArrayGetCount(attachmentsArray) <= 0) {
+ RTC_LOG(LS_ERROR) << "Discarding frame with no attachments.";
+ return;
+ }
+
+ CFDictionaryRef attachment =
+ static_cast<CFDictionaryRef>(CFArrayGetValueAtIndex(attachmentsArray, 0));
+
+ webrtc::MutexLock lock(&_capturer_lock);
+ if (_capturer) {
+ _capturer->OnNewIOSurface(ioSurface, attachment);
+ }
+}
+
+- (void)releaseCapturer {
+ webrtc::MutexLock lock(&_capturer_lock);
+ _capturer = nullptr;
+}
+
+@end