| /* |
| * 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 "rtc_base/time_utils.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. |
| // Also, the `SCContentFilter` fields `contentRect` and `pointPixelScale` were |
| // introduced in macOS 14. |
| API_AVAILABLE(macos(14.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(14.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_); |
| |
| int32_t latest_frame_dpi_ RTC_GUARDED_BY(latest_frame_lock_) = kStandardDPI; |
| |
| // 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() { |
| int64_t capture_start_time_millis = rtc::TimeMillis(); |
| |
| 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(); |
| frame->set_dpi(DesktopVector(latest_frame_dpi_, latest_frame_dpi_)); |
| if (frame_is_dirty_) { |
| frame->mutable_updated_region()->AddRect( |
| DesktopRect::MakeSize(frame->size())); |
| frame_is_dirty_ = false; |
| } |
| } |
| } |
| |
| if (frame) { |
| frame->set_capture_time_ms(rtc::TimeSince(capture_start_time_millis)); |
| 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; |
| config.captureResolution = SCCaptureResolutionNominal; |
| |
| { |
| MutexLock lock(&latest_frame_lock_); |
| latest_frame_dpi_ = filter.pointPixelScale * kStandardDPI; |
| } |
| |
| 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 14.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 { |
| self = [super init]; |
| if (self) { |
| _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 |