Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
60 changes: 60 additions & 0 deletions scripts/prove-live-sck-cancel.sh
Original file line number Diff line number Diff line change
@@ -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" <<PKG
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "LiveSCKProof",
platforms: [.macOS(.v14)],
dependencies: [
.package(path: "${KIT}"),
],
targets: [
.executableTarget(
name: "LiveSCKProof",
dependencies: [
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
]
),
]
)
PKG

echo "Building LiveSCKProof against $KIT ..."
(
cd "$WORK"
swift build -c debug
BIN="$(swift build -c debug --show-bin-path)/LiveSCKProof"
echo "Running: $BIN"
codesign -dv "$BIN" 2>&1 | sed -n '1,12p' || true
echo
"$BIN"
)
Loading
Loading