apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

commit 3a411118e41f4fece4d51a34328c2c6a92e9e478
parent 486fd1c1e92b40379e61c3bdb3a60e06316266fd
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 12:59:25 -0700

capture: harden presentation cleanup

- add exactly-once cleanup hooks to capture callback state
- dismiss media picker and scanner presentations on timeout or cancellation
- preserve coordinator completion idempotence across duplicate callbacks
- cover timeout, cancellation, and double-completion cleanup in tests

Diffstat:
MSources/RadrootsKit/RadrootsAppleDocumentScanner.swift | 11++++++++++-
MSources/RadrootsKit/RadrootsAppleMediaPicker.swift | 121++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
MTests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 179 insertions(+), 21 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleDocumentScanner.swift b/Sources/RadrootsKit/RadrootsAppleDocumentScanner.swift @@ -76,7 +76,7 @@ public final class RadrootsAppleDocumentScanner: RadrootsDocumentScanner, @unche return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( timeout: callbackTimeout, timeoutMessage: "timed out while presenting document scanner" - ) { completion in + ) { completion, setCleanup in let controller = VNDocumentCameraViewController() let coordinator = RadrootsAppleDocumentScannerCoordinator( writer: writer, @@ -85,6 +85,9 @@ public final class RadrootsAppleDocumentScanner: RadrootsDocumentScanner, @unche ) coordinator.completion = completion controller.delegate = coordinator + setCleanup { + coordinator.cancelPresentation(controller) + } RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) presenter.present(controller, animated: true) } @@ -200,6 +203,12 @@ private final class RadrootsAppleDocumentScannerCoordinator: NSObject, @preconcu ) } + func cancelPresentation(_ controller: VNDocumentCameraViewController) { + guard !didResolve else { return } + controller.dismiss(animated: true) + finish(.failure(.transientFailure("document scanner presentation was cancelled"))) + } + private func finish(_ result: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) { guard !didResolve else { return } didResolve = true diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift b/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift @@ -94,7 +94,7 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( timeout: callbackTimeout, timeoutMessage: "timed out while presenting media import" - ) { completion in + ) { completion, setCleanup in var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.selectionLimit = request.selectionLimit configuration.filter = .images @@ -106,6 +106,9 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen ) coordinator.completion = completion picker.delegate = coordinator + setCleanup { + coordinator.cancelPresentation(picker) + } RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) presenter.present(picker, animated: true) } @@ -129,7 +132,7 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( timeout: callbackTimeout, timeoutMessage: "timed out while presenting camera photo capture" - ) { completion in + ) { completion, setCleanup in let picker = UIImagePickerController() picker.sourceType = .camera picker.mediaTypes = [Self.imageTypeIdentifier()] @@ -141,6 +144,9 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen ) coordinator.completion = completion picker.delegate = coordinator + setCleanup { + coordinator.cancelPresentation(picker) + } RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) presenter.present(picker, animated: true) } @@ -366,6 +372,12 @@ private final class RadrootsApplePhotoPickerCoordinator: NSObject, PHPickerViewC #endif } + func cancelPresentation(_ picker: PHPickerViewController) { + guard !didResolve else { return } + picker.dismiss(animated: true) + finish(.failure(.transientFailure("media import presentation was cancelled"))) + } + private func finish(_ result: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) { guard !didResolve else { return } didResolve = true @@ -434,6 +446,12 @@ private final class RadrootsAppleCameraCaptureCoordinator: NSObject, UIImagePick ) } + func cancelPresentation(_ picker: UIImagePickerController) { + guard !didResolve else { return } + picker.dismiss(animated: true) + finish(.failure(.transientFailure("camera photo capture presentation was cancelled"))) + } + private func finish(_ result: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) { guard !didResolve else { return } didResolve = true @@ -636,25 +654,38 @@ enum RadrootsAppleCaptureAsyncSupport { static func awaitMainActorCallback<Value: Sendable>( timeout: TimeInterval, timeoutMessage: String, - operation: @escaping @MainActor (@escaping @Sendable (Result<Value, RadrootsCaptureIntakeError>) -> Void) -> Void + operation: @escaping @MainActor ( + @escaping @Sendable (Result<Value, RadrootsCaptureIntakeError>) -> Void, + @escaping @MainActor @Sendable (@escaping @MainActor @Sendable () -> Void) -> Void + ) -> Void ) async throws -> Value { - try await withCheckedThrowingContinuation { continuation in - let state = RadrootsAppleCaptureAsyncCallbackState(continuation: continuation) - let timeoutTask = Task { - let nanoseconds = UInt64(max(timeout, 0) * 1_000_000_000) - do { - try await Task.sleep(nanoseconds: nanoseconds) - } catch { - return + let state = RadrootsAppleCaptureAsyncCallbackState<Value>() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + state.start(continuation: continuation) + let timeoutTask = Task { + let nanoseconds = UInt64(max(timeout, 0) * 1_000_000_000) + do { + try await Task.sleep(nanoseconds: nanoseconds) + } catch { + return + } + state.resolve(.failure(.transientFailure(timeoutMessage))) } - state.resolve(.failure(.transientFailure(timeoutMessage))) - } - Task { @MainActor in - operation { result in - timeoutTask.cancel() - state.resolve(result) + Task { @MainActor in + operation( + { result in + timeoutTask.cancel() + state.resolve(result) + }, + { cleanup in + state.setCleanup(cleanup) + } + ) } } + } onCancel: { + state.resolve(.failure(.userCancelled("capture request was cancelled"))) } } } @@ -662,24 +693,74 @@ enum RadrootsAppleCaptureAsyncSupport { private final class RadrootsAppleCaptureAsyncCallbackState<Value: Sendable>: @unchecked Sendable { private let lock: NSLock private var continuation: CheckedContinuation<Value, any Error>? + private var cleanup: (@MainActor @Sendable () -> Void)? + private var resolvedResult: Result<Value, RadrootsCaptureIntakeError>? private var didResolve: Bool - init(continuation: CheckedContinuation<Value, any Error>) { + init() { self.lock = NSLock() - self.continuation = continuation + self.continuation = nil + self.cleanup = nil + self.resolvedResult = nil self.didResolve = false } + func start(continuation: CheckedContinuation<Value, any Error>) { + lock.lock() + if let resolvedResult { + lock.unlock() + resume(continuation, with: resolvedResult) + return + } + self.continuation = continuation + lock.unlock() + } + + func setCleanup(_ cleanup: @escaping @MainActor @Sendable () -> Void) { + lock.lock() + let shouldRun = didResolve + if !didResolve { + self.cleanup = cleanup + } + lock.unlock() + if shouldRun { + Task { @MainActor in + cleanup() + } + } + } + func resolve(_ result: Result<Value, RadrootsCaptureIntakeError>) { lock.lock() - guard !didResolve, let continuation else { + guard !didResolve else { lock.unlock() return } didResolve = true + let continuation = self.continuation self.continuation = nil + if continuation == nil { + self.resolvedResult = result + } + let cleanup = self.cleanup + self.cleanup = nil lock.unlock() + if let cleanup { + Task { @MainActor in + cleanup() + } + } + guard let continuation else { + return + } + resume(continuation, with: result) + } + + private func resume( + _ continuation: CheckedContinuation<Value, any Error>, + with result: Result<Value, RadrootsCaptureIntakeError> + ) { switch result { case .success(let value): continuation.resume(returning: value) diff --git a/Tests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift b/Tests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift @@ -2,6 +2,65 @@ import Foundation import Testing @testable import RadrootsKit +@Test func captureAsyncSupportRunsCleanupOnTimeout() async throws { + let probe = RadrootsCaptureCleanupProbe() + + await #expect(throws: RadrootsCaptureIntakeError.transientFailure("timeout")) { + let _: Int = try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( + timeout: 0.001, + timeoutMessage: "timeout" + ) { _, setCleanup in + setCleanup { + probe.recordCleanup() + } + } + } + + try await Task.sleep(nanoseconds: 20_000_000) + #expect(await probe.cleanupCount == 1) +} + +@Test func captureAsyncSupportRunsCleanupOnceOnDoubleCompletion() async throws { + let probe = RadrootsCaptureCleanupProbe() + + let value: Int = try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( + timeout: 10, + timeoutMessage: "timeout" + ) { completion, setCleanup in + setCleanup { + probe.recordCleanup() + } + completion(.success(7)) + completion(.success(8)) + } + + try await Task.sleep(nanoseconds: 20_000_000) + #expect(value == 7) + #expect(await probe.cleanupCount == 1) +} + +@Test func captureAsyncSupportRunsCleanupOnTaskCancellation() async throws { + let probe = RadrootsCaptureCleanupProbe() + let task = Task<Int, any Error> { + try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( + timeout: 10, + timeoutMessage: "timeout" + ) { _, setCleanup in + setCleanup { + probe.recordCleanup() + } + } + } + + task.cancel() + + await #expect(throws: RadrootsCaptureIntakeError.userCancelled("capture request was cancelled")) { + _ = try await task.value + } + try await Task.sleep(nanoseconds: 20_000_000) + #expect(await probe.cleanupCount == 1) +} + #if !canImport(UIKit) @Test func appleMediaPickerReportsUnavailableWithoutUIKit() async throws { let picker = try RadrootsAppleMediaPicker(fileAccess: mediaPickerTestFileAccess()) @@ -32,3 +91,12 @@ private func mediaPickerTestFileAccess() throws -> RadrootsAppleFileAccess { ) return RadrootsAppleFileAccess(roots: roots) } + +@MainActor +private final class RadrootsCaptureCleanupProbe { + private(set) var cleanupCount = 0 + + func recordCleanup() { + cleanupCount += 1 + } +}