Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ target_link_libraries(${PROJECT_NAME} LibVNCServer::vncserver ${CMAKE_THREAD_LIB
add_executable(listwindows src/listwindows.m)
target_link_libraries(listwindows ${APPLICATION_SERVICES})

#
# Code signing with entitlements
#
set(ENTITLEMENTS_FILE ${CMAKE_CURRENT_SOURCE_DIR}/macVNC.entitlements)
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
COMMAND codesign --force --sign - --entitlements ${ENTITLEMENTS_FILE} $<TARGET_FILE:${PROJECT_NAME}>
COMMENT "Signing macVNC binary with entitlements"
)

#
# Install, i.e. finalise bundle
#
Expand Down
9 changes: 9 additions & 0 deletions macVNC.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

7 changes: 7 additions & 0 deletions src/ScreenCapturer.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ NS_ASSUME_NONNULL_BEGIN

- (void)startCapture;
- (void)stopCapture;
- (void)restartCapture;

// Health status properties (read-only)
@property (nonatomic, readonly) NSTimeInterval lastFrameTime;
@property (nonatomic, readonly) uint64_t frameCount;
@property (nonatomic, readonly) uint32_t restartCount;
@property (nonatomic, readonly) BOOL isHealthy;

@end

Expand Down
259 changes: 251 additions & 8 deletions src/ScreenCapturer.m
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ @interface ScreenCapturer ()
@property (nonatomic, copy, nonnull) void (^frameHandler)(CMSampleBufferRef sampleBuffer);
@property (nonatomic, copy, nonnull) void (^errorHandler)(NSError *error);

// Frame monitoring and health tracking
@property (nonatomic, assign) NSTimeInterval lastFrameTime;
@property (nonatomic, assign) uint64_t frameCount;
@property (nonatomic, assign) uint32_t restartCount;
@property (nonatomic, assign) uint32_t consecutiveRestartFailures;
@property (nonatomic, assign) NSTimeInterval frameTimeoutSeconds;
@property (nonatomic, strong) NSTimer *watchdogTimer;
@property (nonatomic, strong) NSTimer *metricsTimer;
@property (nonatomic, assign) BOOL isHealthy;
@property (nonatomic, assign) BOOL isRestarting;
@property (nonatomic, assign) NSTimeInterval lastRestartAttemptTime;
@property (nonatomic, assign) uint64_t lastMetricsFrameCount;
@property (nonatomic, assign) NSTimeInterval lastMetricsTime;

@end


Expand All @@ -22,16 +36,38 @@ - (instancetype)initWithWindowID:(uint32_t)windowID
_windowID = windowID;
_frameHandler = [frameHandler copy];
_errorHandler = [errorHandler copy];

// Initialize frame monitoring
_frameCount = 0;
_restartCount = 0;
_consecutiveRestartFailures = 0;
_frameTimeoutSeconds = 10.0; // 10 second timeout
_lastFrameTime = 0;
_isHealthy = NO; // Will become healthy once frames start flowing
_isStopping = NO;
_isRestarting = NO;
_lastRestartAttemptTime = 0;
_lastMetricsFrameCount = 0;
_lastMetricsTime = 0;

NSLog(@"[ScreenCapturer] Initialized for window ID: %u, frame timeout: %.1f seconds",
windowID, _frameTimeoutSeconds);
}
return self;
}

- (void)startCapture {
NSLog(@"[ScreenCapturer] Starting capture for window ID: %u", self.windowID);

[SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *error) {
if (error) {
NSLog(@"[ScreenCapturer] ERROR: Failed to get shareable content: %@", error);
self.errorHandler(error);
return;
}

NSLog(@"[ScreenCapturer] Got shareable content, found %lu windows", (unsigned long)content.windows.count);

SCStreamConfiguration *config = [[SCStreamConfiguration alloc] init];
// set max frame rate to 60 FPS
config.minimumFrameInterval = CMTimeMake(1, 60);
Expand All @@ -47,18 +83,25 @@ - (void)startCapture {
}
}
if (!selectedWindow) {
NSLog(@"[ScreenCapturer] ERROR: Window %u not found in shareable content", self.windowID);
NSError *noWindowError = [NSError errorWithDomain:@"ScreenCapturerErrorDomain"
code:2
userInfo:@{NSLocalizedDescriptionKey : @"Window not available for capture"}];
self.errorHandler(noWindowError);
return;
}

NSLog(@"[ScreenCapturer] Found target window: %.0fx%.0f",
selectedWindow.frame.size.width, selectedWindow.frame.size.height);

config.width = selectedWindow.frame.size.width;
config.height = selectedWindow.frame.size.height;
if ([SCContentFilter instancesRespondToSelector:@selector(initWithDesktopIndependentWindow:)]) {
filter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:selectedWindow];
NSLog(@"[ScreenCapturer] Using desktop-independent window filter");
} else {
filter = [[SCContentFilter alloc] initWithWindow:selectedWindow excludingWindows:@[]];
NSLog(@"[ScreenCapturer] Using standard window filter");
}
}
self.stream = [[SCStream alloc] initWithFilter:filter configuration:config delegate:self];
Expand All @@ -69,36 +112,64 @@ - (void)startCapture {
sampleHandlerQueue:dispatch_queue_create("libvncserver.examples.mac", NULL)
error:&addOutputError];
if (addOutputError) {
NSLog(@"[ScreenCapturer] ERROR: Failed to add stream output: %@", addOutputError);
self.errorHandler(addOutputError);
return;
}

NSLog(@"[ScreenCapturer] Added stream output, starting capture...");

[self.stream startCaptureWithCompletionHandler:^(NSError * _Nullable startError) {
if (startError) {
NSLog(@"[ScreenCapturer] ERROR: Failed to start capture: %@", startError);
self.errorHandler(startError);
} else {
NSLog(@"[ScreenCapturer] Capture started successfully");
self.lastFrameTime = [[NSDate date] timeIntervalSince1970];
self.isHealthy = YES;

// Start watchdog timer on main thread
dispatch_async(dispatch_get_main_queue(), ^{
[self startWatchdogTimer];
[self startMetricsTimer];
});
}
}];
}];
}

- (void)stopCapture {
if (!self.stream || self.isStopping) {
NSLog(@"[ScreenCapturer] stopCapture called but already stopping or no stream");
return;
}

NSLog(@"[ScreenCapturer] Stopping capture (frames captured: %llu, restarts: %u)",
self.frameCount, self.restartCount);

self.isStopping = YES;
self.isHealthy = NO;

// Stop timers on main thread
dispatch_async(dispatch_get_main_queue(), ^{
[self stopWatchdogTimer];
[self stopMetricsTimer];
});

NSError *removeError = nil;
[self.stream removeStreamOutput:self type:SCStreamOutputTypeScreen error:&removeError];
if (removeError) {
NSLog(@"Warning: removeStreamOutput error: %@", removeError);
NSLog(@"[ScreenCapturer] WARNING: removeStreamOutput error: %@", removeError);
}

SCStream *stream = [self.stream retain];
self.stream = nil;

[stream stopCaptureWithCompletionHandler:^(NSError * _Nullable stopError) {
if (stopError) {
NSLog(@"Stop capture error: %@", stopError);
NSLog(@"[ScreenCapturer] ERROR: Stop capture error: %@", stopError);
} else {
NSLog(@"[ScreenCapturer] Capture stopped successfully");
}
[stream release];
}];
Expand All @@ -110,24 +181,196 @@ - (void)dealloc {
[super dealloc];
}

#pragma mark - Restart Logic

/*
SCStreamDelegate methods
*/
- (void)restartCapture {
if (self.isRestarting) {
NSLog(@"[ScreenCapturer] Restart already in progress, skipping");
return;
}

NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
NSTimeInterval timeSinceLastRestart = now - self.lastRestartAttemptTime;

// Exponential backoff: 2^consecutiveFailures seconds, max 60 seconds
NSTimeInterval minBackoff = MIN(pow(2.0, (double)self.consecutiveRestartFailures), 60.0);
if (timeSinceLastRestart < minBackoff) {
NSLog(@"[ScreenCapturer] Restart backoff in effect (%.1fs since last attempt, need %.1fs)",
timeSinceLastRestart, minBackoff);
return;
}

// Max 10 restart attempts
if (self.restartCount >= 10) {
NSLog(@"[ScreenCapturer] CRITICAL: Max restart attempts (%u) reached, giving up", self.restartCount);
self.isHealthy = NO;
NSError *maxRestartsError = [NSError errorWithDomain:@"ScreenCapturerErrorDomain"
code:3
userInfo:@{NSLocalizedDescriptionKey : @"Max restart attempts reached"}];
self.errorHandler(maxRestartsError);
return;
}

self.isRestarting = YES;
self.lastRestartAttemptTime = now;
self.restartCount++;

NSLog(@"[ScreenCapturer] Attempting restart #%u (consecutive failures: %u)",
self.restartCount, self.consecutiveRestartFailures);

// Stop current capture
BOOL wasStoppingBefore = self.isStopping;
self.isStopping = NO; // Temporarily reset to allow stopCapture to proceed

if (self.stream) {
NSLog(@"[ScreenCapturer] Stopping existing stream before restart");
[self stopCapture];
}

self.isStopping = wasStoppingBefore;

// Wait a moment for ScreenCaptureKit to release resources
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"[ScreenCapturer] Starting capture after restart delay");
self.isRestarting = NO;
[self startCapture];
});
}

#pragma mark - Watchdog Timer

- (void)startWatchdogTimer {
[self stopWatchdogTimer];

NSLog(@"[ScreenCapturer] Starting watchdog timer (checking every %.1f seconds)",
self.frameTimeoutSeconds);

self.watchdogTimer = [NSTimer scheduledTimerWithTimeInterval:self.frameTimeoutSeconds
target:self
selector:@selector(checkFrameTimeout)
userInfo:nil
repeats:YES];
}

- (void)stopWatchdogTimer {
if (self.watchdogTimer) {
NSLog(@"[ScreenCapturer] Stopping watchdog timer");
[self.watchdogTimer invalidate];
self.watchdogTimer = nil;
}
}

- (void)checkFrameTimeout {
if (self.isStopping || self.isRestarting) {
return;
}

NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
NSTimeInterval timeSinceLastFrame = now - self.lastFrameTime;

if (timeSinceLastFrame > self.frameTimeoutSeconds) {
NSLog(@"[ScreenCapturer] ALERT: Frame timeout detected! No frames for %.1f seconds (threshold: %.1f)",
timeSinceLastFrame, self.frameTimeoutSeconds);
NSLog(@"[ScreenCapturer] Total frames received: %llu, restarts so far: %u",
self.frameCount, self.restartCount);

self.isHealthy = NO;
self.consecutiveRestartFailures++;

// Attempt automatic restart
[self restartCapture];
}
}

#pragma mark - Metrics Timer

- (void)startMetricsTimer {
[self stopMetricsTimer];

NSLog(@"[ScreenCapturer] Starting metrics timer (logging every 60 seconds)");

self.lastMetricsTime = [[NSDate date] timeIntervalSince1970];
self.lastMetricsFrameCount = self.frameCount;

self.metricsTimer = [NSTimer scheduledTimerWithTimeInterval:60.0
target:self
selector:@selector(logMetrics)
userInfo:nil
repeats:YES];
}

- (void)stopMetricsTimer {
if (self.metricsTimer) {
NSLog(@"[ScreenCapturer] Stopping metrics timer");
[self.metricsTimer invalidate];
self.metricsTimer = nil;
}
}

- (void)logMetrics {
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
NSTimeInterval elapsed = now - self.lastMetricsTime;
uint64_t framesDelta = self.frameCount - self.lastMetricsFrameCount;

double avgFps = elapsed > 0 ? (double)framesDelta / elapsed : 0.0;
NSTimeInterval timeSinceLastFrame = now - self.lastFrameTime;

NSLog(@"[ScreenCapturer] METRICS: frames=%llu, fps=%.1f, last_frame=%.1fs ago, restarts=%u, healthy=%d",
self.frameCount, avgFps, timeSinceLastFrame, self.restartCount, self.isHealthy);

self.lastMetricsTime = now;
self.lastMetricsFrameCount = self.frameCount;
}

#pragma mark - SCStreamDelegate methods

- (void) stream:(SCStream *) stream didStopWithError:(NSError *) error {
NSLog(@"[ScreenCapturer] DELEGATE: stream didStopWithError called (error code: %ld)",
(long)(error ? error.code : 0));

if (error && error.code != 0) {
NSLog(@"[ScreenCapturer] ERROR: Stream stopped with error: %@", error);
self.isHealthy = NO;
self.errorHandler(error);
} else {
NSLog(@"[ScreenCapturer] Stream stopped without error (may be intentional or silent failure)");

// If we weren't intentionally stopping, this is a problem
if (!self.isStopping && !self.isRestarting) {
NSLog(@"[ScreenCapturer] WARNING: Stream stopped unexpectedly without error!");
self.isHealthy = NO;
self.consecutiveRestartFailures++;

// Attempt restart
dispatch_async(dispatch_get_main_queue(), ^{
[self restartCapture];
});
}
}
}


/*
SCStreamOutput methods
*/
#pragma mark - SCStreamOutput methods

- (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type {
if (type == SCStreamOutputTypeScreen && !self.isStopping) {
// Update frame tracking
self.frameCount++;
self.lastFrameTime = [[NSDate date] timeIntervalSince1970];

// Mark as healthy if we were unhealthy
if (!self.isHealthy) {
NSLog(@"[ScreenCapturer] Capture recovered! Frames flowing again");
self.isHealthy = YES;
self.consecutiveRestartFailures = 0; // Reset failure counter on successful recovery
}

// Log every 300th frame (approximately every 5 seconds at 60fps)
if (self.frameCount % 300 == 0) {
NSLog(@"[ScreenCapturer] Frame checkpoint: %llu frames delivered", self.frameCount);
}

// Call the frame handler
self.frameHandler(sampleBuffer);
}
}
Expand Down