Skip to content
Open
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
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,20 @@ 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?` would swallow CancellationError and continue a second SCK probe after cancel.
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else {
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")
}
}
92 changes: 92 additions & 0 deletions scripts/prove-screencapture-permission-cancel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Standalone proof for fix/screencapture-permission-honor-cancel (PR #280).
// Mirrors ScreenRecordingPermissionChecker transient denial retry:
// unfixed: try? sleep then second probe even after cancel
// fixed: try? sleep + guard !Task.isCancelled before second probe
//
// 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 {
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else {
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 (PR #280) ===")
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)
}
}
}
Loading