diff --git a/CHANGELOG.md b/CHANGELOG.md index 9deaf91a..ccfe9a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [3.5.3] - 2026-06-13 ### Fixed +- Watch captures now honor stop requests during transient ScreenCaptureKit retry backoff instead of waiting out the full delay. Thanks @SebTardif for #193. - Peekaboo agent skill install and usage guidance now uses the current `skills/peekaboo` path, treats observed element IDs as opaque, and keeps screenshot artifacts in explicit temporary paths. Thanks @coygeek for #197. - `peekaboo app list` now excludes accessory/background processes by default, while `--include-background` restores them as documented. - Menu-extra clicks now reject items parked outside active displays by menu bar managers instead of moving the pointer to offscreen coordinates and reporting false success. diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession+Loop.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession+Loop.swift index f422cec2..669b3794 100644 --- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession+Loop.swift +++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession+Loop.swift @@ -77,7 +77,8 @@ extension WatchCaptureSession { } // SCK can report a temporary TCC denial while another CLI capture is settling. // Treat that as a dropped live frame; the next sample or fallback frame can recover. - try await Task.sleep(nanoseconds: delay) + let retryStart = Date() + try await self.sleep(ns: delay, since: retryStart) continue } throw error diff --git a/Core/PeekabooCore/Tests/PeekabooAutomationTests/WatchCaptureSessionTests.swift b/Core/PeekabooCore/Tests/PeekabooAutomationTests/WatchCaptureSessionTests.swift index cc9eefc2..08db677d 100644 --- a/Core/PeekabooCore/Tests/PeekabooAutomationTests/WatchCaptureSessionTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooAutomationTests/WatchCaptureSessionTests.swift @@ -298,6 +298,70 @@ struct WatchCaptureSessionTests { #expect(Date().timeIntervalSince(stopStarted) < 1) } + @Test + @MainActor + func `Stop request wakes transient capture backoff`() async throws { + let png = Self.makePNG(size: CGSize(width: 20, height: 20)) + let capture = StubTransientScreenCaptureService(result: png, size: CGSize(width: 20, height: 20)) + let screens = StubScreenService() + let output = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("watch-transient-stop-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: output) } + + let options = WatchCaptureOptions( + duration: 30, + idleFps: 5, + activeFps: 5, + changeThresholdPercent: 100, + heartbeatSeconds: 0, + quietMsToIdle: 0, + maxFrames: 10, + maxMegabytes: nil, + highlightChanges: false, + captureFocus: .auto, + resolutionCap: nil, + diffStrategy: .fast, + diffBudgetMs: nil) + let session = WatchCaptureSession( + dependencies: WatchCaptureDependencies(screenCapture: capture, screenService: screens), + configuration: WatchCaptureConfiguration( + scope: WatchScope(kind: .frontmost), + options: options, + outputRoot: output, + autoclean: WatchAutocleanConfig(minutes: 1, managed: false))) + + let transientFailure = AsyncStream.makeStream() + capture.onTransientFailure = { + transientFailure.continuation.yield() + } + + let task = Task { @MainActor in + try await session.run() + } + + var sawTransientFailure = false + for await _ in transientFailure.stream { + sawTransientFailure = true + break + } + #expect(sawTransientFailure) + #expect(capture.attemptCount >= 1) + + // Ensure requestStop() lands inside the 350ms transient backoff window. + try await Task.sleep(nanoseconds: 15_000_000) + + let stopStarted = Date() + session.requestStop() + let result = try await task.value + let stopElapsed = Date().timeIntervalSince(stopStarted) + + print("PROOF transient_stop_elapsed_ms=\(Int(stopElapsed * 1000))") + + #expect(result.warnings.contains { $0.code == .transientCaptureFailure }) + // Unfixed raw Task.sleep still waits ~350ms before the loop can observe stop. + #expect(stopElapsed < 0.08) + } + @Test @MainActor func `Task cancellation wakes cadence sleep`() async throws { @@ -469,6 +533,75 @@ struct WatchCaptureSessionTests { // MARK: - Stubs +@MainActor +private final class StubTransientScreenCaptureService: ScreenCaptureServiceProtocol { + private let success: StubScreenCaptureService + private(set) var attemptCount = 0 + var onTransientFailure: (() -> Void)? + + private static let transientError = NSError( + domain: "com.apple.ScreenCaptureKit.SCStreamErrorDomain", + code: -3801, + userInfo: [ + NSLocalizedDescriptionKey: "The user declined TCCs for application, window, display capture", + ]) + + init(result: Data, size: CGSize) { + self.success = StubScreenCaptureService(result: result, size: size) + } + + func captureScreen( + displayIndex _: Int?, + visualizerMode _: CaptureVisualizerMode, + scale _: CaptureScalePreference) async throws -> CaptureResult + { + throw Self.transientError + } + + func captureWindow( + appIdentifier _: String, + windowIndex _: Int?, + visualizerMode _: CaptureVisualizerMode, + scale _: CaptureScalePreference) async throws -> CaptureResult + { + throw Self.transientError + } + + func captureWindow( + windowID _: CGWindowID, + visualizerMode _: CaptureVisualizerMode, + scale _: CaptureScalePreference) async throws -> CaptureResult + { + throw Self.transientError + } + + func captureFrontmost( + visualizerMode: CaptureVisualizerMode, + scale: CaptureScalePreference) async throws -> CaptureResult + { + self.attemptCount += 1 + if self.attemptCount == 1 { + return try await self.success.captureFrontmost( + visualizerMode: visualizerMode, + scale: scale) + } + self.onTransientFailure?() + throw Self.transientError + } + + func captureArea( + _: CGRect, + visualizerMode _: CaptureVisualizerMode, + scale _: CaptureScalePreference) async throws -> CaptureResult + { + throw Self.transientError + } + + func hasScreenRecordingPermission() async -> Bool { + true + } +} + @MainActor private final class StubScreenCaptureService: ScreenCaptureServiceProtocol { private let resultData: Data