[fix] recover macOS screen capture after interruptions, refs #154

This commit is contained in:
dijunkun committed 2026-11-13 17:22:50 +08:00
1 parent d393cd847e
commit 22df6f7d7e
1 file changed
+199 -103
@@ -17,7 +17,7 @@
#include <IOKit/graphics/IOGraphicsLib.h>
#include <IOSurface/IOSurface.h>
#include <ScreenCaptureKit/ScreenCaptureKit.h>
#include <atomic>
#include <chrono>
#include <cstring>
#include <limits>
#include <map>
@@ -35,6 +35,7 @@ using namespace crossdesk;
class ScreenCapturerSckImpl;
static const int kFullDesktopScreenId = -1;
static constexpr auto kCaptureStartTimeout = std::chrono::seconds(5);
static std::string NSErrorToString(NSError *error) {
if (!error) {
@@ -42,7 +43,9 @@ static std::string NSErrorToString(NSError *error) {
}
const char *description = [error.localizedDescription UTF8String];
return description ? description : "";
const char *domain = [error.domain UTF8String];
return std::string(domain ? domain : "NSError") + ":" +
std::to_string(error.code) + " " + (description ? description : "");
}
static void RetainCapturedPixelBuffer(void *owner) {
@@ -122,6 +125,7 @@ API_AVAILABLE(macos(14.0))
// completes.
- (void)releaseCapturer;
- (void)privacyCoversChanged:(NSNotification*)notification;
- (void)scheduleHealthCheck:(uint64_t)session;
- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation
revision:(uint64_t)revision complete:(BOOL)complete
configuration:(SCStreamConfiguration*)configuration error:(NSError*)error;
@@ -163,6 +167,18 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
int fps_ = 60;
bool show_cursor_ = false;
bool capture_requested_ = false;
// Start() accepts an asynchronous request. Only its completion marks the
// native stream running; recovery stays active until Stop()/Destroy().
bool stream_running_ = false;
bool received_frame_ = false;
bool recovery_pending_ = false;
unsigned recovery_attempt_ = 0;
uint64_t capture_session_ = 0;
using Clock = std::chrono::steady_clock;
Clock::time_point capture_attempt_started_{};
Clock::time_point configuration_started_{};
Clock::time_point suspended_since_{};
Clock::time_point next_recovery_{};
bool invalid_stream_id_logged_ = false;
bool native_output_logged_ = false;
PrivacyController* privacy_ = nullptr;
@@ -180,18 +196,12 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
bool complete, SCStreamConfiguration* configuration,
NSError* error);
void RetryPrivacyFilter();
bool CheckCaptureHealth(uint64_t session);
void CaptureConfigurationChanged() { StartOrReconfigureCapturer(); }
void CaptureStopped(SCStream* source, NSError* error) {
{
std::lock_guard lock(lock_);
if (!capture_requested_ || source != stream_) return;
}
LOG_ERROR("ScreenCaptureKit stream stopped: {}", NSErrorToString(error));
if (privacy_) privacy_->Fail("ScreenCaptureKit stream stopped");
}
void CaptureStopped(SCStream* source, NSError* error);
void CaptureSuspended(SCStream* source);
// Called by SckHelper to notify of a newly captured frame. May run on an arbitrary thread.
// void OnNewIOSurface(IOSurfaceRef io_surface, CFDictionaryRef attachment);
void OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer, CFDictionaryRef attachment);
void OnNewCVPixelBuffer(SCStream* source, CVPixelBufferRef pixelBuffer);
private:
// Called when starting the capturer or the configuration has changed (either from a
@@ -199,6 +209,8 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
// 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();
void RecoverCapture(uint64_t generation, const std::string& reason,
SCStream* stopped_stream = nil, bool retry = true);
// 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.
@@ -206,9 +218,6 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
// Callback for returning captured frames to the caller. ScreenCaptureKit may
// invoke it on its sample-handler queue.
cb_desktop_data _on_data = nullptr;
// 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.
std::mutex lock_;
// Provides captured desktop frames.
@@ -313,21 +322,7 @@ ScreenCapturerSckImpl::ScreenCapturerSckImpl() {
}
ScreenCapturerSckImpl::~ScreenCapturerSckImpl() {
SckHelper *helper_to_release = nil;
{
std::lock_guard<std::mutex> lock(lock_);
if (stream_) {
[stream_ stopCaptureWithCompletionHandler:nil];
stream_ = nil;
}
_on_data = nullptr;
helper_to_release = helper_;
helper_ = nil;
}
[helper_to_release releaseCapturer];
display_info_list_.clear();
display_id_map_.clear();
Destroy();
}
int ScreenCapturerSckImpl::Init(const int fps, cb_desktop_data cb) {
@@ -364,7 +359,11 @@ int ScreenCapturerSckImpl::Init(const int fps, cb_desktop_data cb) {
}
dispatch_semaphore_signal(sema);
}];
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
if (dispatch_semaphore_wait(sema, dispatch_time(DISPATCH_TIME_NOW,
5 * NSEC_PER_SEC)) != 0) {
LOG_ERROR("Timed out getting ScreenCaptureKit display info");
return -1;
}
if (capture_error || !content || content.displays.count == 0) {
LOG_ERROR("Failed to get display info, error: {}",
@@ -402,22 +401,27 @@ int ScreenCapturerSckImpl::Init(const int fps, cb_desktop_data cb) {
}
int ScreenCapturerSckImpl::Start(bool show_cursor) {
if (permanent_error_) {
LOG_ERROR("Cannot start capturer: permanent error occurred");
return -1;
}
SckHelper* helper;
uint64_t session;
{
std::lock_guard<std::mutex> lock(lock_);
if (display_info_list_.empty()) {
if (display_info_list_.empty() || !helper_) {
LOG_ERROR("Cannot start capturer: display info not initialized");
return -1;
}
if (capture_requested_) return 0;
show_cursor_ = show_cursor;
capture_requested_ = true;
stream_running_ = received_frame_ = recovery_pending_ = false;
recovery_attempt_ = 0;
suspended_since_ = {};
capture_attempt_started_ = Clock::now();
session = ++capture_session_;
helper = helper_;
invalid_stream_id_logged_ = false;
native_output_logged_ = false;
}
if (privacy_) privacy_->CaptureChanged(true);
[helper scheduleHealthCheck:session];
StartOrReconfigureCapturer();
return 0;
}
@@ -475,22 +479,12 @@ int ScreenCapturerSckImpl::ResetToInitialMonitor() {
}
int ScreenCapturerSckImpl::Destroy() {
if (privacy_) privacy_->CaptureChanged(false);
Stop();
SckHelper *helper_to_release = nil;
{
std::lock_guard<std::mutex> lock(lock_);
if (stream_) {
LOG_INFO("Destroying stream");
[stream_ stopCaptureWithCompletionHandler:nil];
stream_ = nil;
}
capture_requested_ = false;
configuration_in_flight_ = reconfigure_pending_ = false;
applied_configuration_ = nil;
++configuration_generation_;
current_display_ = 0;
current_stream_id_.clear();
permanent_error_ = false;
_on_data = nullptr;
helper_to_release = helper_;
helper_ = nil;
@@ -502,9 +496,12 @@ int ScreenCapturerSckImpl::Destroy() {
}
int ScreenCapturerSckImpl::Stop() {
if (privacy_) privacy_->CaptureChanged(false);
std::lock_guard<std::mutex> lock(lock_);
capture_requested_ = false;
stream_running_ = received_frame_ = recovery_pending_ = false;
recovery_attempt_ = 0;
suspended_since_ = {};
++capture_session_;
configuration_in_flight_ = reconfigure_pending_ = false;
applied_configuration_ = nil;
++configuration_generation_;
@@ -514,6 +511,7 @@ int ScreenCapturerSckImpl::Stop() {
stream_ = nil;
}
current_display_ = 0;
if (privacy_) privacy_->CaptureChanged(false);
return 0;
}
@@ -526,6 +524,7 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
LOG_INFO("Ignoring stale ScreenCaptureKit display refresh after stop");
return;
}
configuration_in_flight_ = false;
}
// The window list belongs to the privacy revision present when the request
@@ -536,18 +535,12 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
}
if (!content) {
LOG_ERROR("getShareableContent failed");
if (privacy_) privacy_->Fail("Could not refresh ScreenCaptureKit privacy content");
std::lock_guard lock(lock_);
if (!stream_) permanent_error_ = true;
RecoverCapture(generation, "getShareableContent failed");
return;
}
if (!content.displays || content.displays.count == 0) {
LOG_ERROR("getShareableContent returned no displays");
if (privacy_) privacy_->Fail("No shareable displays available for privacy");
std::lock_guard lock(lock_);
if (!stream_) permanent_error_ = true;
RecoverCapture(generation, "getShareableContent returned no displays");
return;
}
@@ -620,8 +613,7 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
}
if (!captured_display) {
LOG_ERROR("Failed to find valid display");
permanent_error_ = true;
RecoverCapture(generation, "Failed to find valid display");
return;
}
@@ -660,15 +652,13 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
SCContentFilter *filter = [[SCContentFilter alloc] initWithDisplay:captured_display
excludingWindows:excluded];
if (!filter) {
LOG_ERROR("Failed to create SCContentFilter");
permanent_error_ = true;
RecoverCapture(generation, "Failed to create SCContentFilter");
return;
}
SCStreamConfiguration *config = [[SCStreamConfiguration alloc] init];
if (!config) {
LOG_ERROR("Failed to create SCStreamConfiguration");
permanent_error_ = true;
RecoverCapture(generation, "Failed to create SCStreamConfiguration");
return;
}
@@ -725,10 +715,9 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
error:&add_stream_output_error];
if (!add_stream_output_result) {
configuration_in_flight_ = false;
stream_ = nil;
LOG_ERROR("addStreamOutput failed: {}", NSErrorToString(add_stream_output_error));
permanent_error_ = true;
lock.unlock();
RecoverCapture(generation, "addStreamOutput failed: " +
NSErrorToString(add_stream_output_error));
return;
}
@@ -762,13 +751,16 @@ void ScreenCapturerSckImpl::ConfigurationFinished(SCStream* source, uint64_t gen
bool refresh = false;
{
std::lock_guard lock(lock_);
if (!capture_requested_ || source != stream_) return;
if (!capture_requested_ || source != stream_ ||
generation != configuration_generation_) return;
configuration_in_flight_ = false;
const auto current = GetMacPrivacyCaptureState();
refresh = reconfigure_pending_ || current.revision != revision;
reconfigure_pending_ = false;
if (!error) {
stream_running_ = true;
applied_configuration_ = configuration;
if (privacy_) privacy_->CaptureChanged(true);
if (complete && !refresh && generation == configuration_generation_)
AcknowledgeMacPrivacyCapture(revision);
refresh = refresh || !complete;
@@ -776,14 +768,104 @@ void ScreenCapturerSckImpl::ConfigurationFinished(SCStream* source, uint64_t gen
LOG_INFO("macOS privacy filter completed: generation={}, revision={}, complete={}, refresh={}, error={}",
generation, revision, complete, refresh, NSErrorToString(error));
}
if (error && privacy_)
privacy_->Fail("ScreenCaptureKit privacy filter: " + NSErrorToString(error));
if (error) {
RecoverCapture(generation, "ScreenCaptureKit configuration failed: " +
NSErrorToString(error));
return;
}
if (refresh) RetryPrivacyFilter();
}
void ScreenCapturerSckImpl::OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer,
CFDictionaryRef attachment) {
(void)attachment;
void ScreenCapturerSckImpl::RecoverCapture(uint64_t generation,
const std::string& reason,
SCStream* stopped_stream, bool retry) {
SCStream* old_stream;
{
std::lock_guard lock(lock_);
// A stop invalidates the whole stream, including any newer configuration
// request. Enumeration/operation failures only invalidate their generation.
if (!capture_requested_ ||
(stopped_stream ? stopped_stream != stream_
: generation != configuration_generation_)) return;
old_stream = stream_;
stream_ = nil;
stream_running_ = received_frame_ = false;
configuration_in_flight_ = reconfigure_pending_ = false;
applied_configuration_ = nil;
suspended_since_ = {};
++configuration_generation_; // Revoke pending completions and frames.
recovery_pending_ = retry;
if (retry) {
const auto delay = std::chrono::seconds(
std::min(1u << std::min(recovery_attempt_, 3u), 5u));
recovery_attempt_ = std::min(recovery_attempt_ + 1, 4u);
next_recovery_ = Clock::now() + delay;
LOG_WARN("ScreenCaptureKit recovery: {}; retry in {} s", reason, delay.count());
} else {
capture_requested_ = false;
++capture_session_;
LOG_INFO("ScreenCaptureKit capture ended: {}", reason);
}
// A replacement stream must never inherit an acknowledged privacy filter.
if (privacy_) {
privacy_->Fail(reason);
privacy_->CaptureChanged(false);
}
}
[old_stream stopCaptureWithCompletionHandler:nil];
}
bool ScreenCapturerSckImpl::CheckCaptureHealth(uint64_t session) {
uint64_t generation;
bool retry = false;
const char* failure = nullptr;
{
std::lock_guard lock(lock_);
if (!capture_requested_ || session != capture_session_) return false;
generation = configuration_generation_;
const auto now = Clock::now();
if (recovery_pending_) {
if (now >= next_recovery_) {
recovery_pending_ = false;
capture_attempt_started_ = now;
retry = true;
}
} else if (configuration_in_flight_ &&
now - configuration_started_ >= kCaptureStartTimeout) {
failure = "ScreenCaptureKit configuration timed out";
} else if ((!stream_running_ || !received_frame_) &&
now - capture_attempt_started_ >= kCaptureStartTimeout) {
failure = "ScreenCaptureKit did not deliver its first frame";
} else if (suspended_since_ != Clock::time_point{} &&
now - suspended_since_ >= kCaptureStartTimeout) {
failure = "ScreenCaptureKit frame updates remained suspended";
}
}
if (failure) RecoverCapture(generation, failure);
if (retry) StartOrReconfigureCapturer();
return true;
}
void ScreenCapturerSckImpl::CaptureStopped(SCStream* source, NSError* error) {
if (!source) return;
// The system's explicit Stop Sharing action cancels capture until the next
// connection. Only unexpected/system interruptions should restart it.
const bool user_stopped = [error.domain isEqualToString:SCStreamErrorDomain] &&
error.code == SCStreamErrorUserStopped;
RecoverCapture(0, "ScreenCaptureKit stream stopped: " + NSErrorToString(error),
source, !user_stopped);
}
void ScreenCapturerSckImpl::CaptureSuspended(SCStream* source) {
std::lock_guard lock(lock_);
if (capture_requested_ && source == stream_ &&
suspended_since_ == Clock::time_point{}) {
suspended_since_ = Clock::now();
}
}
void ScreenCapturerSckImpl::OnNewCVPixelBuffer(SCStream* source,
CVPixelBufferRef pixelBuffer) {
if (!pixelBuffer) {
return;
}
@@ -813,7 +895,7 @@ void ScreenCapturerSckImpl::OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer,
bool log_native_output = false;
{
std::lock_guard<std::mutex> lock(lock_);
if (!_on_data || !capture_requested_) {
if (!_on_data || !capture_requested_ || source != stream_) {
return;
}
stream_id = ResolveDisplayStreamId(current_stream_id_.c_str(),
@@ -829,6 +911,9 @@ void ScreenCapturerSckImpl::OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer,
return;
}
invalid_stream_id_logged_ = false;
received_frame_ = true;
suspended_since_ = {};
recovery_attempt_ = 0;
callback = _on_data;
if (!native_output_logged_) {
native_output_logged_ = true;
@@ -854,27 +939,17 @@ void ScreenCapturerSckImpl::OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer,
}
void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
if (permanent_error_) {
LOG_ERROR("Cannot reconfigure capturer: permanent error occurred");
return;
}
if (@available(macOS 10.15, *)) {
bool has_permission = CGPreflightScreenCaptureAccess();
if (!has_permission) {
LOG_ERROR("Screen recording permission not granted");
permanent_error_ = true;
return;
}
}
SckHelper *local_helper;
uint64_t generation;
uint64_t revision;
{
std::lock_guard lock(lock_);
if (!capture_requested_) return;
if (!capture_requested_ || recovery_pending_) return;
if (configuration_in_flight_) { reconfigure_pending_ = true; return; }
// Include content enumeration in the in-flight operation so screen-change
// notifications cannot keep superseding a slow request while locked.
configuration_in_flight_ = true;
configuration_started_ = Clock::now();
reconfigure_pending_ = false;
local_helper = helper_;
generation = ++configuration_generation_;
@@ -885,6 +960,11 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
return;
}
if (!CGPreflightScreenCaptureAccess()) {
RecoverCapture(generation, "Screen recording permission not granted");
return;
}
auto handler = ^(SCShareableContent *content, NSError *error) {
if (error) {
LOG_ERROR("getShareableContent failed: {}", NSErrorToString(error));
@@ -924,6 +1004,16 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer) _capturer->CaptureConfigurationChanged();
}
- (void)scheduleHealthCheck:(uint64_t)session {
// Use the completion queue rather than the UI queue: capture recovery must
// also progress while the app is in the background or the screen is locked.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC),
_completion_queue, ^{
std::lock_guard<std::mutex> lock(self->_capturer_lock);
if (self->_capturer && self->_capturer->CheckCaptureHealth(session))
[self scheduleHealthCheck:session];
});
}
- (void)screenParametersChanged:(NSNotification*)notification {
// Defer until the mode switch has completed, then fetch fresh SCDisplay
// geometry and update the stream's pixel dimensions and input bounds.
@@ -959,29 +1049,35 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
- (void)stream:(SCStream *)stream
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
ofType:(SCStreamOutputType)type {
CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
if (!pixelBuffer) {
return;
}
CFRetain(pixelBuffer);
if (type != SCStreamOutputTypeScreen || !CMSampleBufferIsValid(sampleBuffer)) return;
CFArrayRef attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, false);
if (!attachmentsArray || CFArrayGetCount(attachmentsArray) == 0) {
LOG_ERROR("Discarding frame with no attachments");
CFRelease(pixelBuffer);
return;
}
CFDictionaryRef attachment =
static_cast<CFDictionaryRef>(CFArrayGetValueAtIndex(attachmentsArray, 0));
NSNumber* status = (__bridge NSNumber*)CFDictionaryGetValue(
attachment, (__bridge CFStringRef)SCStreamFrameInfoStatus);
if (!status) return;
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer) {
_capturer->OnNewCVPixelBuffer(pixelBuffer, attachment);
if (!_capturer) return;
// Status-only buffers may have no image. Observe suspension before asking
// for a pixel buffer; ordinary idle frames must not trigger recovery.
switch (status.integerValue) {
case SCFrameStatusBlank:
case SCFrameStatusSuspended:
case SCFrameStatusStopped:
_capturer->CaptureSuspended(stream);
return;
case SCFrameStatusComplete:
break;
default:
return;
}
CFRelease(pixelBuffer);
// The sample buffer owns its image for the duration of this callback.
_capturer->OnNewCVPixelBuffer(stream, CMSampleBufferGetImageBuffer(sampleBuffer));
}
- (void)releaseCapturer {