diff --git a/CMakeLists.txt b/CMakeLists.txt index dac06c1..0721fef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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} $ + COMMENT "Signing macVNC binary with entitlements" +) + # # Install, i.e. finalise bundle # diff --git a/macVNC.entitlements b/macVNC.entitlements new file mode 100644 index 0000000..1ec5e6a --- /dev/null +++ b/macVNC.entitlements @@ -0,0 +1,9 @@ + + + + + com.apple.security.cs.disable-library-validation + + + + diff --git a/src/ScreenCapturer.h b/src/ScreenCapturer.h index 313dbe9..6dbfc42 100644 --- a/src/ScreenCapturer.h +++ b/src/ScreenCapturer.h @@ -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 diff --git a/src/ScreenCapturer.m b/src/ScreenCapturer.m index 43330e8..3ee54d2 100644 --- a/src/ScreenCapturer.m +++ b/src/ScreenCapturer.m @@ -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 @@ -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); @@ -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]; @@ -69,13 +112,27 @@ - (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]; + }); } }]; }]; @@ -83,14 +140,26 @@ - (void)startCapture { - (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]; @@ -98,7 +167,9 @@ - (void)stopCapture { [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]; }]; @@ -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); } }