@@ -7,6 +7,8 @@ import ScreenCaptureKit
77final class ScreenCaptureService : Sendable {
88 private let maxSize : CGFloat = 3000
99 private let jpegQuality : CGFloat = 0.8
10+ private static let activeWindowResolveTimeoutNs : UInt64 = 500_000_000 // 500ms
11+ private static let activeWindowCacheTTL : TimeInterval = 10
1012
1113 /// Serializes all reads and writes to axFailureCountByBundleID and axSystemwideDisabled.
1214 /// Both vars are accessed from the MainActor (captureFrame start) AND the cooperative
@@ -23,6 +25,17 @@ final class ScreenCaptureService: Sendable {
2325 /// Must be accessed only while holding axStateLock.
2426 nonisolated ( unsafe) private static var axSystemwideDisabled = false
2527
28+ /// Cache the last successfully resolved active window to avoid losing capture
29+ /// when the resolver times out or transiently fails.
30+ private struct ActiveWindowSnapshot {
31+ let appName : String ?
32+ let windowTitle : String ?
33+ let windowID : CGWindowID ?
34+ let resolvedAt : Date
35+ }
36+ nonisolated ( unsafe) private static var lastActiveWindowSnapshot : ActiveWindowSnapshot ?
37+ nonisolated ( unsafe) private static var isActiveWindowResolutionInFlight = false
38+
2639 init ( ) { }
2740
2841 /// Check if we have screen recording permission by actually testing capture
@@ -371,6 +384,87 @@ final class ScreenCaptureService: Sendable {
371384 return windowID
372385 }
373386
387+ /// Resolve active window info asynchronously with timeout and cache fallback.
388+ /// This prevents rare SkyLight/CGWindowList stalls from blocking capture.
389+ static func getActiveWindowInfoAsync( ) async -> (
390+ appName: String ? , windowTitle: String ? , windowID: CGWindowID ?
391+ ) {
392+ // Avoid stacking multiple slow window enumeration tasks if one is already in flight.
393+ let shouldStartNewResolution = axStateLock. withLock { ( ) -> Bool in
394+ if isActiveWindowResolutionInFlight {
395+ return false
396+ }
397+ isActiveWindowResolutionInFlight = true
398+ return true
399+ }
400+
401+ if !shouldStartNewResolution {
402+ if let cached = getCachedActiveWindowSnapshot ( ) {
403+ return ( cached. appName, cached. windowTitle, cached. windowID)
404+ }
405+ return ( nil , nil , nil )
406+ }
407+
408+ defer {
409+ axStateLock. withLock {
410+ isActiveWindowResolutionInFlight = false
411+ }
412+ }
413+
414+ let resolved = await resolveActiveWindowInfoWithTimeout ( )
415+ if let resolved {
416+ let snapshot = ActiveWindowSnapshot (
417+ appName: resolved. appName,
418+ windowTitle: resolved. windowTitle,
419+ windowID: resolved. windowID,
420+ resolvedAt: Date ( )
421+ )
422+ axStateLock. withLock {
423+ lastActiveWindowSnapshot = snapshot
424+ }
425+ return resolved
426+ }
427+
428+ if let cached = getCachedActiveWindowSnapshot ( ) {
429+ log ( " ScreenCaptureService: Active window lookup timed out, using cached window info " )
430+ return ( cached. appName, cached. windowTitle, cached. windowID)
431+ }
432+
433+ log ( " ScreenCaptureService: Active window lookup timed out with no cached fallback " )
434+ return ( nil , nil , nil )
435+ }
436+
437+ private static func resolveActiveWindowInfoWithTimeout( ) async -> (
438+ appName: String ? , windowTitle: String ? , windowID: CGWindowID ?
439+ ) ? {
440+ await withTaskGroup ( of: ( appName: String? , windowTitle: String? , windowID: CGWindowID? ) ? . self) { group in
441+ group. addTask ( priority: . userInitiated) {
442+ let info = getActiveWindowInfo ( )
443+ if info. appName == nil && info. windowTitle == nil && info. windowID == nil {
444+ return nil
445+ }
446+ return info
447+ }
448+
449+ group. addTask {
450+ try ? await Task . sleep ( nanoseconds: activeWindowResolveTimeoutNs)
451+ return nil
452+ }
453+
454+ let firstCompleted = await group. next ( ) ?? nil
455+ group. cancelAll ( )
456+ return firstCompleted
457+ }
458+ }
459+
460+ private static func getCachedActiveWindowSnapshot( ) -> ActiveWindowSnapshot ? {
461+ axStateLock. withLock {
462+ guard let snapshot = lastActiveWindowSnapshot else { return nil }
463+ guard Date ( ) . timeIntervalSince ( snapshot. resolvedAt) <= activeWindowCacheTTL else { return nil }
464+ return snapshot
465+ }
466+ }
467+
374468 /// Get the active app name, window title, and window ID
375469 static func getActiveWindowInfo( ) -> (
376470 appName: String ? , windowTitle: String ? , windowID: CGWindowID ?
@@ -554,7 +648,8 @@ final class ScreenCaptureService: Sendable {
554648
555649 /// Async capture - main entry point
556650 func captureActiveWindowAsync( ) async -> Data ? {
557- guard let windowID = Self . getActiveWindowID ( ) else {
651+ let ( _, _, windowID) = await Self . getActiveWindowInfoAsync ( )
652+ guard let windowID else {
558653 log ( " No active window ID found " )
559654 return nil
560655 }
@@ -628,7 +723,8 @@ final class ScreenCaptureService: Sendable {
628723 /// Use this on macOS 14+ to avoid redundant encode/decode round-trips.
629724 @available ( macOS 14 . 0 , * )
630725 func captureActiveWindowCGImage( ) async -> CGImage ? {
631- guard let windowID = Self . getActiveWindowID ( ) else {
726+ let ( _, _, windowID) = await Self . getActiveWindowInfoAsync ( )
727+ guard let windowID else {
632728 log ( " No active window ID found " )
633729 return nil
634730 }
0 commit comments