diff --git a/CHANGELOG.md b/CHANGELOG.md index af481beef..daf00f764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Prevent dialog discovery and element traversal from recursing indefinitely when an app reports cyclic accessibility relationships. +- Canceling during a ScreenCaptureKit transient-denial retry sleep no longer proceeds into a second capture attempt or permission probe. ## [3.9.6] - 2026-07-19 diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/ScreenCaptureEngineSupport.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/ScreenCaptureEngineSupport.swift index d92a0690d..8bfeb1052 100644 --- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/ScreenCaptureEngineSupport.swift +++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/ScreenCaptureEngineSupport.swift @@ -179,7 +179,9 @@ struct NullScreenCaptureMetricsObserver: ScreenCaptureMetricsObserving { "\(api.description) capture hit transient ScreenCaptureKit denial; retrying once", metadata: ["error": String(describing: error)], correlationId: correlationId) - try? await Task.sleep(nanoseconds: delay) + // `try?` would swallow CancellationError and burn a full capture retry after cancel. + try await Task.sleep(nanoseconds: delay) + try Task.checkCancellation() do { let start = Date() let result = try await attempt(api) @@ -254,7 +256,9 @@ struct NullScreenCaptureMetricsObserver: ScreenCaptureMetricsObserving { "\(api.description) capture hit transient ScreenCaptureKit denial; retrying once", metadata: ["error": String(describing: error)], correlationId: correlationId) - try? await Task.sleep(nanoseconds: delay) + // `try?` would swallow CancellationError and burn a full capture retry after cancel. + try await Task.sleep(nanoseconds: delay) + try Task.checkCancellation() do { let start = Date() let result = try await attempt(api) diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/ScreenCapturePermissionGate.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/ScreenCapturePermissionGate.swift index 8d2ee6228..533a7cef9 100644 --- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/ScreenCapturePermissionGate.swift +++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/ScreenCapturePermissionGate.swift @@ -6,9 +6,29 @@ import Foundation func hasPermission(logger: CategoryLogger) async -> Bool } -struct ScreenRecordingPermissionChecker: ScreenRecordingPermissionEvaluating { - func hasPermission(logger: CategoryLogger) async -> Bool { - let preflightResult = CGPreflightScreenCaptureAccess() +@MainActor +@_spi(Testing) public struct ScreenRecordingPermissionChecker: ScreenRecordingPermissionEvaluating { + private let preflight: @MainActor @Sendable () -> Bool + private let shareableContentProbe: @MainActor @Sendable () async throws -> Void + + public init() { + self.preflight = { CGPreflightScreenCaptureAccess() } + self.shareableContentProbe = { + _ = try await ScreenCaptureKitCaptureGate.currentShareableContent() + } + } + + /// Test seam: inject preflight and shareable-content probe results. + @_spi(Testing) public init( + preflight: @escaping @MainActor @Sendable () -> Bool, + shareableContentProbe: @escaping @MainActor @Sendable () async throws -> Void) + { + self.preflight = preflight + self.shareableContentProbe = shareableContentProbe + } + + public func hasPermission(logger: CategoryLogger) async -> Bool { + let preflightResult = self.preflight() if preflightResult { return true } @@ -17,16 +37,24 @@ struct ScreenRecordingPermissionChecker: ScreenRecordingPermissionEvaluating { // granted because TCC tracks by code signature and the check can fail after rebuilds or for non-.app bundles. logger.debug("CGPreflightScreenCaptureAccess returned false, probing SCShareableContent") do { - _ = try await ScreenCaptureKitCaptureGate.currentShareableContent() + try await self.shareableContentProbe() logger.info("Screen recording permission granted (SCShareableContent probe)") return true } catch { if let delay = ScreenCaptureKitTransientError.retryDelayNanoseconds(after: error) { logger.warning( "Screen recording permission probe hit transient ScreenCaptureKit denial; retrying once") - try? await Task.sleep(nanoseconds: delay) + // `try?` would swallow CancellationError and continue a second SCK probe after cancel. + do { + try await Task.sleep(nanoseconds: delay) + try Task.checkCancellation() + } catch { + // Task.sleep / checkCancellation only throw CancellationError in practice; + // map any failure here to not-granted so a canceled task never starts a second probe. + return false + } do { - _ = try await ScreenCaptureKitCaptureGate.currentShareableContent() + try await self.shareableContentProbe() logger.info("Screen recording permission granted (SCShareableContent retry)") return true } catch { diff --git a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ScreenRecordingPermissionCancelTests.swift b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ScreenRecordingPermissionCancelTests.swift new file mode 100644 index 000000000..09999640c --- /dev/null +++ b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ScreenRecordingPermissionCancelTests.swift @@ -0,0 +1,58 @@ +import Foundation +import XCTest +@testable @_spi(Testing) import PeekabooAutomationKit + +@MainActor +final class ScreenRecordingPermissionCancelTests: XCTestCase { + func testCancelDuringTransientPermissionRetrySkipsSecondProbe() async { + let logging = MockLoggingService() + let logger = logging.logger(category: "test") + var probeCount = 0 + + let checker = ScreenRecordingPermissionChecker( + preflight: { false }, + shareableContentProbe: { + probeCount += 1 + throw NSError( + domain: "com.apple.ScreenCaptureKit.SCStreamErrorDomain", + code: -3801, + userInfo: [ + NSLocalizedDescriptionKey: "The user declined TCCs for application, window, display capture", + ]) + }) + + let task = Task { @MainActor in + await checker.hasPermission(logger: logger) + } + + // Cancel during the 350ms transient-denial sleep. + try? await Task.sleep(nanoseconds: 50_000_000) + task.cancel() + let granted = await task.value + + XCTAssertFalse(granted) + XCTAssertEqual(probeCount, 1, "canceled permission probe must not run a second SCShareableContent call") + } + + func testNonCancelledTransientPermissionRetryProbesTwice() async { + let logging = MockLoggingService() + let logger = logging.logger(category: "test") + var probeCount = 0 + + let checker = ScreenRecordingPermissionChecker( + preflight: { false }, + shareableContentProbe: { + probeCount += 1 + throw NSError( + domain: "com.apple.ScreenCaptureKit.SCStreamErrorDomain", + code: -3801, + userInfo: [ + NSLocalizedDescriptionKey: "The user declined TCCs for application, window, display capture", + ]) + }) + + let granted = await checker.hasPermission(logger: logger) + XCTAssertFalse(granted) + XCTAssertEqual(probeCount, 2, "non-cancelled transient denial still retries once") + } +} diff --git a/Core/PeekabooCore/Tests/PeekabooTests/ScreenCaptureFallbackRunnerTests.swift b/Core/PeekabooCore/Tests/PeekabooTests/ScreenCaptureFallbackRunnerTests.swift index 3f5993f7c..777854c21 100644 --- a/Core/PeekabooCore/Tests/PeekabooTests/ScreenCaptureFallbackRunnerTests.swift +++ b/Core/PeekabooCore/Tests/PeekabooTests/ScreenCaptureFallbackRunnerTests.swift @@ -150,4 +150,79 @@ struct ScreenCaptureFallbackRunnerTests { #expect(value == "ok_modern") #expect(calls == [.modern, .modern]) } + + /// Transient ScreenCaptureKit denial triggers a 350ms sleep. Cancelling during that sleep + /// must not invoke a second capture attempt (the pre-fix `try?` path swallowed cancel). + @MainActor + @Test + func `cancel during transient denial sleep skips second generic attempt`() async throws { + let logger = LoggingService(subsystem: "test.logger").logger(category: "test") + let runner = ScreenCaptureFallbackRunner(apis: [.modern]) + var attempts = 0 + + let task = Task { @MainActor in + try await runner.run( + operationName: "test", + logger: logger, + correlationId: "cancel-run") + { _ in + attempts += 1 + throw NSError( + domain: "com.apple.ScreenCaptureKit.SCStreamErrorDomain", + code: -3801, + userInfo: [ + NSLocalizedDescriptionKey: "The user declined TCCs for application, window, display capture", + ]) + } + } + + // Cancel while the 350ms transient-denial sleep is in progress. + try await Task.sleep(nanoseconds: 50_000_000) + task.cancel() + + do { + _ = try await task.value + Issue.record("expected cancellation without a second attempt") + } catch { + // CancellationError or first-attempt failure without retry is fine. + } + + #expect(attempts == 1) + } + + @MainActor + @Test + func `cancel during transient denial sleep skips second capture attempt`() async throws { + let logger = LoggingService(subsystem: "test.logger").logger(category: "test") + let runner = ScreenCaptureFallbackRunner(apis: [.modern]) + var attempts = 0 + + let task = Task { @MainActor in + try await runner.runCapture( + operationName: "captureScreen", + logger: logger, + correlationId: "cancel-capture") + { _ in + attempts += 1 + throw NSError( + domain: "com.apple.ScreenCaptureKit.SCStreamErrorDomain", + code: -3801, + userInfo: [ + NSLocalizedDescriptionKey: "The user declined TCCs for application, window, display capture", + ]) + } + } + + try await Task.sleep(nanoseconds: 50_000_000) + task.cancel() + + do { + _ = try await task.value + Issue.record("expected cancellation or failure without second attempt") + } catch { + // Expected: CancellationError or first-attempt error without retry. + } + + #expect(attempts == 1) + } } diff --git a/scripts/prove-live-sck-cancel.sh b/scripts/prove-live-sck-cancel.sh new file mode 100755 index 000000000..58b3d80c2 --- /dev/null +++ b/scripts/prove-live-sck-cancel.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Live ScreenCaptureKit proof for cancel-during-retry on ScreenCaptureFallbackRunner. +# Builds a tiny package that depends on Core/PeekabooAutomationKit and runs real SCK. +# +# Requirements: macOS with ScreenCaptureKit (Screen Recording grant for the host process). +# Usage (repo root): +# bash scripts/prove-live-sck-cancel.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +KIT="${ROOT}/Core/PeekabooAutomationKit" +SRC="${ROOT}/scripts/prove-live-sck-cancel/main.swift" +WORK="${TMPDIR:-/tmp}/peekaboo-live-sck-cancel-$$" + +if [[ ! -d "$KIT" ]]; then + echo "error: PeekabooAutomationKit not found at $KIT" >&2 + exit 1 +fi +if [[ ! -f "$SRC" ]]; then + echo "error: missing $SRC" >&2 + exit 1 +fi + +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +mkdir -p "$WORK/Sources/LiveSCKProof" +cp "$SRC" "$WORK/Sources/LiveSCKProof/main.swift" + +cat > "$WORK/Package.swift" <&1 | sed -n '1,12p' || true + echo + "$BIN" +) diff --git a/scripts/prove-live-sck-cancel/main.swift b/scripts/prove-live-sck-cancel/main.swift new file mode 100644 index 000000000..9c6c61be4 --- /dev/null +++ b/scripts/prove-live-sck-cancel/main.swift @@ -0,0 +1,123 @@ +// Live proof harness for ScreenCaptureFallbackRunner cancel-during-retry. +// Uses production PeekabooAutomationKit (SPI Testing) and real ScreenCaptureKit. +// +// Not a TCC-denial simulation: elicits a real SCStreamErrorDomain failure +// (invalid stream geometry) which production ScreenCaptureKitTransientError +// treats as retryable (any ScreenCaptureKit domain). +// +// Usage (from repo root): +// bash scripts/prove-live-sck-cancel.sh + +import CoreMedia +import Foundation +import ScreenCaptureKit +@_spi(Testing) import PeekabooAutomationKit + +/// Force a real ScreenCaptureKit stream error (domain SCStreamErrorDomain). +/// Production retry classifier treats any SCK-domain error as retryable (~350ms). +@MainActor +func realTransientDenial() async throws { + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + guard let display = content.displays.first else { + throw NSError(domain: "LiveSCKProof", code: 1, userInfo: [NSLocalizedDescriptionKey: "no display"]) + } + let filter = SCContentFilter(display: display, excludingWindows: []) + let config = SCStreamConfiguration() + // Invalid geometry: real SCStreamErrorDomain failure from ScreenCaptureKit. + config.width = 0 + config.height = 0 + let stream = SCStream(filter: filter, configuration: config, delegate: nil) + try await stream.startCapture() +} + +@main +enum LiveSCKProof { + static func main() async { + print("=== Live ScreenCaptureKit cancel proof (production ScreenCaptureFallbackRunner) ===") + print() + + do { + let content = try await SCShareableContent.current + print("live SCShareableContent.current: displays=\(content.displays.count)") + } catch { + print("FATAL: live SCShareableContent failed: \(error)") + exit(1) + } + + do { + try await realTransientDenial() + print("FATAL: expected real SCK error from zero-size stream") + exit(1) + } catch { + let e = error as NSError + print("live seed error: domain=\(e.domain) code=\(e.code) desc=\(e.localizedDescription)") + guard e.domain.localizedCaseInsensitiveContains("ScreenCaptureKit") else { + print("FATAL: not an SCK-domain error; production retry classifier would skip") + exit(1) + } + print("seed is SCK-domain (production classifier matches ScreenCaptureKit domain)") + } + print() + + let logger = LoggingService(subsystem: "live.sck.proof").logger(category: "proof") + + print("Control: no cancel (expect 2 real SCStream start attempts via production runner)") + var controlAttempts = 0 + let controlRunner = ScreenCaptureFallbackRunner(apis: [.modern]) + do { + _ = try await controlRunner.run( + operationName: "live-control", + logger: logger, + correlationId: "live-control") + { _ in + controlAttempts += 1 + try await realTransientDenial() + return 0 + } + print("FAIL control: unexpected success") + exit(1) + } catch { + let e = error as NSError + print("control attempts=\(controlAttempts) final domain=\(e.domain) code=\(e.code)") + } + guard controlAttempts == 2 else { + print("FAIL control: expected 2 attempts, got \(controlAttempts)") + exit(1) + } + print("PASS control: non-canceled path retried once (2 real SCK calls)") + print() + + print("Cancel: cancel ~50ms into 350ms denial sleep (expect 1 real SCStream start attempt)") + var cancelAttempts = 0 + let cancelRunner = ScreenCaptureFallbackRunner(apis: [.modern]) + let task = Task { @MainActor in + try await cancelRunner.run( + operationName: "live-cancel", + logger: logger, + correlationId: "live-cancel") + { _ in + cancelAttempts += 1 + try await realTransientDenial() + return 0 + } + } + try? await Task.sleep(nanoseconds: 50_000_000) + task.cancel() + do { + _ = try await task.value + print("FAIL cancel: unexpected success") + exit(1) + } catch is CancellationError { + print("cancel attempts=\(cancelAttempts) outcome=CancellationError") + } catch { + print("cancel attempts=\(cancelAttempts) outcome=\(type(of: error)) \(error)") + } + guard cancelAttempts == 1 else { + print("FAIL cancel: expected 1 attempt, got \(cancelAttempts)") + exit(1) + } + print("PASS cancel: canceled during denial sleep with no second real SCK call") + print() + print("ALL LIVE CHECKS PASSED") + } +} diff --git a/scripts/prove-screencapture-engine-cancel.swift b/scripts/prove-screencapture-engine-cancel.swift new file mode 100644 index 000000000..b170db2d1 --- /dev/null +++ b/scripts/prove-screencapture-engine-cancel.swift @@ -0,0 +1,103 @@ +// Standalone proof for fix/screencapture-engine-honor-cancel (combined cancel fix). +// Mirrors ScreenCaptureFallbackRunner transient-denial sleep: +// unfixed: try? await Task.sleep - cancel is swallowed, second attempt runs +// fixed: try await Task.sleep + checkCancellation - second attempt skipped +// +// Usage: +// swiftc -parse-as-library -o /tmp/prove-sck-engine scripts/prove-screencapture-engine-cancel.swift +// /tmp/prove-sck-engine + +import Foundation + +enum TransientDenial: Error { + case declined +} + +/// Unfixed: try? swallows CancellationError during the 350ms denial sleep. +func unfixedRetry(attempt: @escaping () async throws -> Int) async -> (attempts: Int, elapsedMs: Double, outcome: String) { + var attempts = 0 + let start = Date() + do { + attempts += 1 + _ = try await attempt() + return (attempts, Date().timeIntervalSince(start) * 1000, "success") + } catch { + // Simulated ScreenCaptureKitTransientError.retryDelayNanoseconds == 350ms + try? await Task.sleep(nanoseconds: 350_000_000) + // No cancellation check - second attempt always runs after sleep returns. + do { + attempts += 1 + _ = try await attempt() + return (attempts, Date().timeIntervalSince(start) * 1000, "retry-success") + } catch { + return (attempts, Date().timeIntervalSince(start) * 1000, "retry-failed") + } + } +} + +/// Fixed: throwing sleep + checkCancellation. +func fixedRetry(attempt: @escaping () async throws -> Int) async -> (attempts: Int, elapsedMs: Double, outcome: String) { + var attempts = 0 + let start = Date() + do { + attempts += 1 + _ = try await attempt() + return (attempts, Date().timeIntervalSince(start) * 1000, "success") + } catch { + do { + try await Task.sleep(nanoseconds: 350_000_000) + try Task.checkCancellation() + attempts += 1 + _ = try await attempt() + return (attempts, Date().timeIntervalSince(start) * 1000, "retry-success") + } catch is CancellationError { + return (attempts, Date().timeIntervalSince(start) * 1000, "cancelled") + } catch { + return (attempts, Date().timeIntervalSince(start) * 1000, "retry-failed") + } + } +} + +@main +struct Proof { + static func main() async { + print("=== ScreenCaptureKit engine transient-denial cancel proof (combined cancel fix) ===") + print("Pattern matches ScreenCaptureFallbackRunner.run / runCapture") + print() + + let failing: () async throws -> Int = { + throw TransientDenial.declined + } + + print("Test 1: Unfixed (try? sleep) - cancel after 50ms into 350ms delay") + let unfixedTask = Task { + await unfixedRetry(attempt: failing) + } + try? await Task.sleep(nanoseconds: 50_000_000) + unfixedTask.cancel() + let unfixed = await unfixedTask.value + print(" attempts=\(unfixed.attempts) elapsed_ms=\(String(format: "%.0f", unfixed.elapsedMs)) outcome=\(unfixed.outcome)") + + print("Test 2: Fixed (try sleep + checkCancellation) - cancel after 50ms into 350ms delay") + let fixedTask = Task { + await fixedRetry(attempt: failing) + } + try? await Task.sleep(nanoseconds: 50_000_000) + fixedTask.cancel() + let fixed = await fixedTask.value + print(" attempts=\(fixed.attempts) elapsed_ms=\(String(format: "%.0f", fixed.elapsedMs)) outcome=\(fixed.outcome)") + print() + + let unfixedBurnedRetry = unfixed.attempts >= 2 + let fixedStopped = fixed.attempts == 1 && fixed.outcome == "cancelled" + if unfixedBurnedRetry, fixedStopped { + print("PASS: unfixed burned a second capture attempt after cancel (\(unfixed.attempts) attempts)") + print("PASS: fixed cancelled during denial sleep without second attempt (\(fixed.attempts) attempt, \(String(format: "%.0f", fixed.elapsedMs))ms)") + } else { + print("FAIL: unexpected results") + print(" unfixed attempts=\(unfixed.attempts) outcome=\(unfixed.outcome)") + print(" fixed attempts=\(fixed.attempts) outcome=\(fixed.outcome)") + exit(1) + } + } +} diff --git a/scripts/prove-screencapture-permission-cancel.swift b/scripts/prove-screencapture-permission-cancel.swift new file mode 100644 index 000000000..149184a32 --- /dev/null +++ b/scripts/prove-screencapture-permission-cancel.swift @@ -0,0 +1,96 @@ +// Standalone proof for fix/screencapture-permission-honor-cancel (combined cancel fix). +// Mirrors ScreenRecordingPermissionChecker transient denial retry: +// unfixed: try? sleep then second probe even after cancel +// fixed: try sleep + checkCancellation; map CancellationError to not-granted +// +// Usage: +// swiftc -parse-as-library -o /tmp/prove-sck-perm scripts/prove-screencapture-permission-cancel.swift +// /tmp/prove-sck-perm + +import Foundation + +func unfixedPermissionProbe(probe: @escaping () async throws -> Void) async -> (probes: Int, elapsedMs: Double, granted: Bool) { + var probes = 0 + let start = Date() + do { + probes += 1 + try await probe() + return (probes, Date().timeIntervalSince(start) * 1000, true) + } catch { + try? await Task.sleep(nanoseconds: 350_000_000) + // No cancel guard - second probe always runs. + do { + probes += 1 + try await probe() + return (probes, Date().timeIntervalSince(start) * 1000, true) + } catch { + return (probes, Date().timeIntervalSince(start) * 1000, false) + } + } +} + +func fixedPermissionProbe(probe: @escaping () async throws -> Void) async -> (probes: Int, elapsedMs: Double, granted: Bool) { + var probes = 0 + let start = Date() + do { + probes += 1 + try await probe() + return (probes, Date().timeIntervalSince(start) * 1000, true) + } catch { + do { + try await Task.sleep(nanoseconds: 350_000_000) + try Task.checkCancellation() + } catch is CancellationError { + return (probes, Date().timeIntervalSince(start) * 1000, false) + } catch { + return (probes, Date().timeIntervalSince(start) * 1000, false) + } + do { + probes += 1 + try await probe() + return (probes, Date().timeIntervalSince(start) * 1000, true) + } catch { + return (probes, Date().timeIntervalSince(start) * 1000, false) + } + } +} + +@main +struct Proof { + static func main() async { + print("=== Screen-recording permission probe cancel proof (combined cancel fix) ===") + print("Pattern matches ScreenRecordingPermissionChecker.hasPermission") + print() + + let failing: () async throws -> Void = { + throw NSError(domain: "com.apple.ScreenCaptureKit.SCStreamErrorDomain", code: -3801) + } + + print("Test 1: Unfixed - cancel after 50ms into 350ms delay") + let unfixedTask = Task { + await unfixedPermissionProbe(probe: failing) + } + try? await Task.sleep(nanoseconds: 50_000_000) + unfixedTask.cancel() + let unfixed = await unfixedTask.value + print(" probes=\(unfixed.probes) elapsed_ms=\(String(format: "%.0f", unfixed.elapsedMs)) granted=\(unfixed.granted)") + + print("Test 2: Fixed - cancel after 50ms into 350ms delay") + let fixedTask = Task { + await fixedPermissionProbe(probe: failing) + } + try? await Task.sleep(nanoseconds: 50_000_000) + fixedTask.cancel() + let fixed = await fixedTask.value + print(" probes=\(fixed.probes) elapsed_ms=\(String(format: "%.0f", fixed.elapsedMs)) granted=\(fixed.granted)") + print() + + if unfixed.probes >= 2, fixed.probes == 1, fixed.granted == false { + print("PASS: unfixed ran second SCShareableContent probe after cancel (\(unfixed.probes) probes)") + print("PASS: fixed stopped at first probe without second SCK call (\(fixed.probes) probe, \(String(format: "%.0f", fixed.elapsedMs))ms)") + } else { + print("FAIL") + exit(1) + } + } +}