Skip to content

Commit 0dca93d

Browse files
committed
desktop: make active window resolution timeout-safe for rewind capture
1 parent 510b406 commit 0dca93d

File tree

3 files changed

+104
-3
lines changed

3 files changed

+104
-3
lines changed

desktop/Desktop/Sources/ProactiveAssistants/Core/WindowMonitor.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ class WindowMonitor {
4949
return ScreenCaptureService.getActiveWindowInfo()
5050
}
5151

52+
/// Async active window lookup with timeout and cache fallback.
53+
static func getActiveWindowInfoAsync() async -> (appName: String?, windowTitle: String?, windowID: CGWindowID?) {
54+
return await ScreenCaptureService.getActiveWindowInfoAsync()
55+
}
56+
5257
/// Instance method for getting active window info
5358
func getActiveWindowInfo() -> (appName: String?, windowTitle: String?, windowID: CGWindowID?) {
5459
return Self.getActiveWindowInfoStatic()

desktop/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ public class ProactiveAssistantsPlugin: NSObject {
576576
}
577577

578578
// Get current window info (use real app name, not cached)
579-
let (realAppName, windowTitle, windowID) = WindowMonitor.getActiveWindowInfoStatic()
579+
let (realAppName, windowTitle, windowID) = await WindowMonitor.getActiveWindowInfoAsync()
580580

581581
// Check if the current app is excluded from Rewind capture
582582
let isRewindExcluded = realAppName.map { RewindSettings.shared.isAppExcluded($0) } ?? false

desktop/Desktop/Sources/ScreenCaptureService.swift

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import ScreenCaptureKit
77
final 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

Comments
 (0)