commit 6b88be1850529477e6192e10bd0666444d74bb1c
parent 1dc3ca9d212c22e5a1ba7196f9fce858c18fbe36
Author: triesap <tyson@radroots.org>
Date: Tue, 15 Sep 2026 01:11:03 +0000
transfer: reserve durable native execution identity
- Reserve each attempt across awaits and atomically persist redacted state
- Bind immutable upload leases and callbacks to recoverable execution UUIDs
- Preserve terminal receipts and ambiguous effects across cancellation and restart
- Verify independent owners, exact API and complete native test suites
Diffstat:
27 files changed, 4057 insertions(+), 3093 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift
@@ -1,314 +1,47 @@
import Foundation
-public struct RadrootsAppleBackgroundTransferAdapters: Sendable {
- public let now: @Sendable () -> Date
- public let enqueue: @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void
- public let cancel: @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void
- public let activeTransferIdentifiers: @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>
- public let handleBackgroundEvents: @Sendable (String, @escaping @Sendable () -> Void) async -> Void
-
- public init(
- now: @escaping @Sendable () -> Date = Date.init,
- enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void,
- cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void,
- activeTransferIdentifiers:
- @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>,
- handleBackgroundEvents:
- @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void
- ) {
- self.now = now
- self.enqueue = enqueue
- self.cancel = cancel
- self.activeTransferIdentifiers = activeTransferIdentifiers
- self.handleBackgroundEvents = handleBackgroundEvents
- }
-
- public static let unavailable = Self(
- enqueue: { _ in
- throw RadrootsBackgroundTransferError.unavailable
- },
- cancel: { _ in
- throw RadrootsBackgroundTransferError.unavailable
- },
- activeTransferIdentifiers: {
- throw RadrootsBackgroundTransferError.unavailable
- }, handleBackgroundEvents: { _, completionHandler in completionHandler() }
- )
-
- public static func live(
- sessionIdentifier: String, store: any RadrootsBackgroundTransferStore,
- fileResolver: any RadrootsBackgroundTransferFileResolver,
- downloadStagingRoot: URL, now: @escaping @Sendable () -> Date = Date.init
- ) throws -> Self {
- #if os(iOS)
- let normalizedSessionIdentifier =
- try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier)
- let session = RadrootsAppleBackgroundURLSession(
- identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver,
- downloadStagingRoot: downloadStagingRoot,
- now: now
- )
- #if targetEnvironment(simulator)
- let simulatorSession = RadrootsAppleBackgroundURLSession(
- identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver,
- downloadStagingRoot: downloadStagingRoot,
- now: now, usesForegroundSession: true
- )
- #endif
- return Self(
- now: now,
- enqueue: { request in
- #if targetEnvironment(simulator)
- if request.networkPolicy == .simulatorLoopbackHTTP {
- try await simulatorSession.enqueue(request)
- return
- }
- #endif
- try await session.enqueue(request)
- },
- cancel: { identifier in
- #if targetEnvironment(simulator)
- await simulatorSession.cancel(identifier)
- #endif
- await session.cancel(identifier)
- },
- activeTransferIdentifiers: {
- var identifiers = await session.activeTransferIdentifiers()
- #if targetEnvironment(simulator)
- identifiers.formUnion(await simulatorSession.activeTransferIdentifiers())
- #endif
- return identifiers
- },
- handleBackgroundEvents: { identifier, completionHandler in
- await session.handleBackgroundEvents(
- identifier: identifier, completionHandler: completionHandler)
- }
- )
- #else
- return .unavailable
- #endif
- }
-}
-
public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer {
- private let store: any RadrootsBackgroundTransferStore
- private let adapters: RadrootsAppleBackgroundTransferAdapters
-
- public init(
- store: any RadrootsBackgroundTransferStore, adapters: RadrootsAppleBackgroundTransferAdapters
- ) {
- self.store = store
- self.adapters = adapters
- }
-
- public init(roots: RadrootsAppleFileRoots, sessionIdentifier: String) throws {
- let store = RadrootsAppleBackgroundTransferStore(roots: roots)
- let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- let downloadStagingRoot = try roots.resolvedURL(
- for: RadrootsFileReference(
- scope: .temporary,
- relativePath:
- "background_transfers/\(RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier))/downloads"
- ),
- allowRootDirectory: true
- )
- self.store = store
- adapters = try .live(
- sessionIdentifier: sessionIdentifier, store: store, fileResolver: resolver,
- downloadStagingRoot: downloadStagingRoot
- )
- }
-
- public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- {
- guard
- try await loadStoredSnapshots().contains(where: { $0.identifier == request.identifier })
- == false
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- return try await schedule(request)
- }
-
- public func retry(_ request: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- {
- guard
- let existing = try await loadStoredSnapshots().first(where: {
- $0.identifier == request.identifier
- }),
- existing.state == .failed || existing.state == .interrupted || existing.state == .cancelled
- || existing.state == .expired,
- try existing.request.redactedForPersistence() == request.redactedForPersistence()
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- do {
- guard try await !adapters.activeTransferIdentifiers().contains(request.identifier) else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- } catch let error as RadrootsBackgroundTransferError {
- throw error
- } catch {
- throw RadrootsBackgroundTransferError.transferFailure
- }
- return try await schedule(request)
- }
+ private let store: any RadrootsBackgroundTransferStore
+ private let adapters: RadrootsAppleBackgroundTransferAdapters
+ private var admissions: Set<RadrootsBackgroundTransferIdentifier> = []
+ private var stoppedAdmissions: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferState] = [:]
- private func schedule(_ request: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- {
- try await saveStoredSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: request, state: .queued, updatedAt: adapters.now()))
- do { try await adapters.enqueue(request) } catch {
- do {
- try await saveStoredSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: request, state: .failed, failure: .enqueueFailed,
- updatedAt: adapters.now()
- )
- )
- } catch {
- throw RadrootsBackgroundTransferError.persistenceFailure
- }
- throw RadrootsBackgroundTransferError.transferFailure
- }
- let current = try await loadStoredSnapshots().first { $0.identifier == request.identifier }
- if current?.state == .queued {
- try await saveStoredSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: request, state: .running, updatedAt: adapters.now())
- )
- }
- return RadrootsBackgroundTransferHandle(request: request)
- }
-
- public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
- if let existing = try await loadStoredSnapshots().first(where: { $0.identifier == identifier }) {
- guard
- existing.state == .queued || existing.state == .running || existing.state == .interrupted
- else { return }
- try await saveStoredSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: existing.request, state: .cancelled, progress: existing.progress,
- possibleRemoteOrphan: existing.request.isUpload && existing.state == .running,
- updatedAt: adapters.now()
- )
- )
+ public init(store: any RadrootsBackgroundTransferStore, adapters: RadrootsAppleBackgroundTransferAdapters) {
+ self.store = store
+ self.adapters = adapters
}
- do { try await adapters.cancel(identifier) } catch {
- throw RadrootsBackgroundTransferError.transferFailure
- }
- }
- public func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
- if let existing = try await loadStoredSnapshots().first(where: { $0.identifier == identifier }) {
- guard
- existing.state == .queued || existing.state == .running || existing.state == .interrupted
- else { return }
- try await saveStoredSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: existing.request, state: .expired, progress: existing.progress,
- failure: .expired,
- possibleRemoteOrphan: existing.request.isUpload && existing.state == .running,
- updatedAt: adapters.now()
- )
- )
+ public init(roots: RadrootsAppleFileRoots, sessionIdentifier: String) throws {
+ let store = RadrootsAppleBackgroundTransferStore(roots: roots)
+ let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ let name = try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier)
+ let downloadRoot = try roots.resolvedURL(for: RadrootsFileReference(
+ scope: .temporary, relativePath: "background_transfers/\(name)/downloads"
+ ), allowRootDirectory: true)
+ self.store = store
+ adapters = try .live(sessionIdentifier: name, store: store, fileResolver: resolver,
+ downloadStagingRoot: downloadRoot)
}
- do { try await adapters.cancel(identifier) } catch {
- throw RadrootsBackgroundTransferError.transferFailure
- }
- }
- public func settle(
- _ identifier: RadrootsBackgroundTransferIdentifier,
- verification: RadrootsBackgroundTransferVerification
- ) async throws {
- guard
- let existing = try await loadStoredSnapshots().first(where: { $0.identifier == identifier }),
- existing.state == .awaitingVerification
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
+ public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle {
+ try reserve(request.identifier)
+ defer { release(request.identifier) }
+ return try await admission(request, retry: false)
}
- switch verification {
- case .accepted:
- try await saveStoredSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: existing.request, state: .completed, progress: existing.progress,
- response: existing.response,
- downloadedArtifact: existing.downloadedArtifact, updatedAt: adapters.now()
- )
- )
- case .rejected(let failure):
- try await saveStoredSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: existing.request, state: .failed, progress: existing.progress,
- failure: failure,
- possibleRemoteOrphan: existing.request.isUpload, updatedAt: adapters.now()
- )
- )
- }
- }
- public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
- -> RadrootsBackgroundTransferSnapshot?
- {
- try await snapshots().first { $0.identifier == identifier }
- }
-
- public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
- let activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier>
- do { activeIdentifiers = try await adapters.activeTransferIdentifiers() } catch {
- throw RadrootsBackgroundTransferError.transferFailure
- }
- let storedSnapshots = try await loadStoredSnapshots()
- let storedIdentifiers = Set(storedSnapshots.map(\.identifier))
- for orphanedIdentifier in activeIdentifiers.subtracting(storedIdentifiers) {
- do { try await adapters.cancel(orphanedIdentifier) } catch {
- throw RadrootsBackgroundTransferError.transferFailure
- }
- }
- var reconciled: [RadrootsBackgroundTransferSnapshot] = []
- for snapshot in storedSnapshots {
- if activeIdentifiers.contains(snapshot.identifier),
- snapshot.state == .queued || snapshot.state == .interrupted
- {
- let runningSnapshot = try RadrootsBackgroundTransferSnapshot(
- request: snapshot.request, state: .running, progress: snapshot.progress,
- failure: snapshot.failure,
- updatedAt: adapters.now()
- )
- try await saveStoredSnapshot(runningSnapshot)
- reconciled.append(runningSnapshot)
- } else if !activeIdentifiers.contains(snapshot.identifier),
- snapshot.state == .queued || snapshot.state == .running
- {
- let interruptedSnapshot = try RadrootsBackgroundTransferSnapshot(
- request: snapshot.request, state: .interrupted, progress: snapshot.progress,
- failure: .interrupted,
- possibleRemoteOrphan: snapshot.request.isUpload && snapshot.state == .running,
- updatedAt: adapters.now()
- )
- try await saveStoredSnapshot(interruptedSnapshot)
- reconciled.append(interruptedSnapshot)
- } else {
- reconciled.append(snapshot)
- }
+ public func retry(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle {
+ try reserve(request.identifier)
+ defer { release(request.identifier) }
+ return try await admission(request, retry: true)
}
- return reconciled.sorted { left, right in left.identifier < right.identifier }
- }
-
- public func handleEventsForBackgroundURLSession(
- identifier: String, completionHandler: @escaping @Sendable () -> Void
- ) async {
- await adapters.handleBackgroundEvents(identifier, completionHandler)
- }
- private func loadStoredSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ private func admission(
+ _ request: RadrootsBackgroundTransferRequest, retry: Bool
+ ) async throws -> RadrootsBackgroundTransferHandle {
do {
- return try await store.loadSnapshots()
+ return try await store.withAdmission(for: request.identifier) {
+ try await self.admitReserved(request, retry: retry)
+ }
} catch let error as RadrootsBackgroundTransferError {
throw error
} catch {
@@ -316,796 +49,216 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer {
}
}
- private func saveStoredSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws {
- do {
- try await store.saveSnapshot(snapshot)
- } catch let error as RadrootsBackgroundTransferError {
- throw error
- } catch {
- throw RadrootsBackgroundTransferError.persistenceFailure
+ private func admitReserved(
+ _ request: RadrootsBackgroundTransferRequest, retry: Bool
+ ) async throws -> RadrootsBackgroundTransferHandle {
+ let existing = try await load(request.identifier)
+ if retry {
+ guard let existing, [.failed, .interrupted, .cancelled, .expired].contains(existing.state),
+ try existing.request.redactedForPersistence() == request.redactedForPersistence()
+ else { throw RadrootsBackgroundTransferError.invalidRequest }
+ } else if existing != nil {
+ throw RadrootsBackgroundTransferError.invalidRequest
}
+ guard try await !activeIdentifiers().contains(request.identifier) else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ return try await schedule(request, replacing: existing)
}
-}
-
-enum RadrootsStagedBackgroundDownloadResult: Sendable, Equatable {
- case file(URL)
- case failure
-}
-
-struct RadrootsBackgroundHTTPResult: Sendable, Equatable {
- let statusCode: Int?
- let mediaType: String?
- let body: Data?
- let contentEncoding: String?
- let bodyExceeded: Bool
- let mediaTypeWasMalformed: Bool
-
- init(
- statusCode: Int?, mediaType: String?, body: Data?, contentEncoding: String? = nil,
- bodyExceeded: Bool,
- mediaTypeWasMalformed: Bool = false
- ) {
- self.statusCode = statusCode
- self.mediaType = mediaType
- self.body = body
- self.contentEncoding = contentEncoding
- self.bodyExceeded = bodyExceeded
- self.mediaTypeWasMalformed = mediaTypeWasMalformed
- }
-}
-
-actor RadrootsAppleBackgroundTransferCoordinator {
- private let sessionIdentifier: String
- private let store: any RadrootsBackgroundTransferStore
- private let fileResolver: any RadrootsBackgroundTransferFileResolver
- private let now: @Sendable () -> Date
- private let fileManager: FileManager
- private var completionHandlers: [@Sendable () -> Void]
- private var unclaimedFinishedEventCount: Int
-
- init(
- sessionIdentifier: String, store: any RadrootsBackgroundTransferStore,
- fileResolver: any RadrootsBackgroundTransferFileResolver,
- now: @escaping @Sendable () -> Date = Date.init, fileManager: FileManager = .default
- ) {
- self.sessionIdentifier = sessionIdentifier
- self.store = store
- self.fileResolver = fileResolver
- self.now = now
- self.fileManager = fileManager
- completionHandlers = []
- unclaimedFinishedEventCount = 0
- }
-
- func updateProgress(
- identifier: RadrootsBackgroundTransferIdentifier, bytesTransferred: Int64,
- totalBytesExpected: Int64?
- ) async {
- guard let existing = try? await snapshot(for: identifier),
- existing.state == .running || existing.state == .queued
- else { return }
- guard
- let progress = Self.progress(
- bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected,
- fallback: existing.progress
- )
- else { return }
- try? await store.saveSnapshot(
- try RadrootsBackgroundTransferSnapshot(
- request: existing.request, state: .running, progress: progress,
- failure: existing.failure,
- response: existing.response, possibleRemoteOrphan: existing.possibleRemoteOrphan,
- updatedAt: now()
- )
- )
- }
-
- func complete(
- identifier: RadrootsBackgroundTransferIdentifier, platformError: Error?,
- stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?,
- httpResult: RadrootsBackgroundHTTPResult, bytesTransferred: Int64,
- totalBytesExpected: Int64?
- ) async {
- guard let existing = try? await snapshot(for: identifier),
- existing.state == .queued || existing.state == .running || existing.state == .interrupted
- else { return }
- if UInt64(max(bytesTransferred, 0)) > existing.request.maximumTransferBytes
- || totalBytesExpected.map({ UInt64(max($0, 0)) > existing.request.maximumTransferBytes })
- == true
- {
- Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager)
- await fail(
- existing: existing,
- code: .transferTooLarge,
- possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0
- )
- return
- }
- if httpResult.bodyExceeded {
- Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager)
- await fail(
- existing: existing, code: .responseTooLarge,
- possibleRemoteOrphan: existing.request.isUpload)
- return
- }
- if httpResult.mediaTypeWasMalformed {
- Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager)
- await fail(
- existing: existing, code: .responseMediaType,
- possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0)
- return
- }
- if platformError != nil {
- Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager)
- await fail(
- existing: existing, code: .platformFailure,
- possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0
- )
- return
- }
- guard let statusCode = httpResult.statusCode else {
- Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager)
- await fail(
- existing: existing, code: .responseMissing,
- possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0
- )
- return
- }
- guard (200...299).contains(statusCode) else {
- Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager)
- await fail(
- existing: existing,
- code: .httpStatus,
- possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0
- )
- return
- }
- let response: RadrootsBackgroundTransferResponse
- do {
- response = try Self.validatedResponse(for: existing.request, httpResult: httpResult)
- } catch let validationError as RadrootsBackgroundResponseValidationError {
- Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager)
- await fail(
- existing: existing, code: validationError.failure,
- possibleRemoteOrphan: existing.request.isUpload)
- return
- } catch {
- Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager)
- await fail(
- existing: existing, code: .responseInvalid,
- possibleRemoteOrphan: existing.request.isUpload)
- return
- }
- switch existing.request.operation {
- case .download(let destination):
- await completeDownload(
- existing: existing, destination: destination, stagedDownloadResult: stagedDownloadResult,
- response: response, mediaType: httpResult.mediaType,
- bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected
- )
- case .upload:
- await completeUpload(
- existing: existing, response: response, bytesTransferred: bytesTransferred,
- totalBytesExpected: totalBytesExpected
- )
- }
- }
-
- func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) {
- guard identifier == sessionIdentifier else {
- completionHandler()
- return
- }
- if unclaimedFinishedEventCount > 0 {
- unclaimedFinishedEventCount -= 1
- completionHandler()
- return
- }
- guard completionHandlers.count < 8 else {
- completionHandler()
- return
- }
- completionHandlers.append(completionHandler)
- }
-
- func finishBackgroundEvents(identifier: String?) {
- guard identifier == nil || identifier == sessionIdentifier else { return }
- guard !completionHandlers.isEmpty else {
- unclaimedFinishedEventCount = min(unclaimedFinishedEventCount + 1, 8)
- return
- }
- let handlers = completionHandlers
- completionHandlers.removeAll()
- for handler in handlers {
- handler()
- }
- }
-
- private func completeUpload(
- existing: RadrootsBackgroundTransferSnapshot, response: RadrootsBackgroundTransferResponse,
- bytesTransferred: Int64,
- totalBytesExpected: Int64?
- ) async {
- let progress =
- Self.progress(
- bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected,
- fallback: existing.progress)
- ?? existing.progress
- try? await store.saveSnapshot(
- try RadrootsBackgroundTransferSnapshot(
- request: existing.request, state: .awaitingVerification, progress: progress,
- response: response, updatedAt: now()
- )
- )
- }
-
- private func completeDownload(
- existing: RadrootsBackgroundTransferSnapshot, destination: RadrootsBackgroundTransferLocalFile,
- stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?,
- response: RadrootsBackgroundTransferResponse, mediaType: String?,
- bytesTransferred: Int64, totalBytesExpected: Int64?
- ) async {
- guard case .file(let stagedFileURL) = stagedDownloadResult else {
- await fail(existing: existing, code: .downloadStagingFailure)
- return
- }
- do {
- guard case .file(let destinationReference) = destination else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- let destinationURL = try fileResolver.resolve(destination)
- let fileSize = try Self.fileSize(at: stagedFileURL, fileManager: fileManager)
- guard fileSize > 0, UInt64(fileSize) <= existing.request.maximumTransferBytes else {
- throw RadrootsBackgroundTransferError.transferFailure
- }
- let downloadedArtifact = try RadrootsBackgroundDownloadedArtifact(
- file: destinationReference,
- sha256: RadrootsAppleFileDigest.sha256(at: stagedFileURL),
- byteSize: UInt64(fileSize),
- mediaType: mediaType
- )
- try fileManager.createDirectory(
- at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
- try Self.moveReplacingItem(from: stagedFileURL, to: destinationURL, fileManager: fileManager)
- #if os(iOS)
- try fileManager.setAttributes(
- [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
- ofItemAtPath: destinationURL.path
- )
- #endif
- let progress =
- Self.progress(
- bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected,
- fallback: existing.progress
- )
- ?? existing.progress
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: existing.request, state: .awaitingVerification, progress: progress,
- response: response,
- downloadedArtifact: downloadedArtifact, updatedAt: now()
- )
- )
- } catch {
- Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager)
- await fail(existing: existing, code: .destinationFailure)
- }
- }
-
- private func fail(
- existing: RadrootsBackgroundTransferSnapshot,
- code: RadrootsBackgroundTransferFailure,
- possibleRemoteOrphan: Bool = false
- ) async {
- try? await store.saveSnapshot(
- try RadrootsBackgroundTransferSnapshot(
- request: existing.request, state: .failed, progress: existing.progress, failure: code,
- possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: now()
- )
- )
- }
-
- private func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
- -> RadrootsBackgroundTransferSnapshot?
- {
- try await store.loadSnapshots().first { $0.identifier == identifier }
- }
-
- private static func progress(
- bytesTransferred: Int64, totalBytesExpected: Int64?,
- fallback: RadrootsBackgroundTransferProgress
- )
- -> RadrootsBackgroundTransferProgress?
- {
- let safeBytesTransferred = max(bytesTransferred, fallback.bytesTransferred)
- let safeTotalBytesExpected =
- totalBytesExpected.flatMap { value -> Int64? in value >= safeBytesTransferred ? value : nil }
- ?? fallback.totalBytesExpected.flatMap { value -> Int64? in
- value >= safeBytesTransferred ? value : nil
- }
- return try? RadrootsBackgroundTransferProgress(
- bytesTransferred: safeBytesTransferred, totalBytesExpected: safeTotalBytesExpected)
- }
-
- private static func fileSize(at url: URL, fileManager _: FileManager) throws -> Int64 {
- let values = try url.resourceValues(forKeys: [.fileSizeKey])
- return Int64(values.fileSize ?? 0)
- }
-
- private static func moveReplacingItem(
- from source: URL, to destination: URL, fileManager: FileManager
- ) throws {
- guard fileManager.fileExists(atPath: destination.path) else {
- try fileManager.moveItem(at: source, to: destination)
- return
- }
- let backup = destination.deletingLastPathComponent().appendingPathComponent(
- ".radroots-transfer-backup-\(UUID().uuidString.lowercased())"
- )
- try fileManager.moveItem(at: destination, to: backup)
- do {
- try fileManager.moveItem(at: source, to: destination)
- try fileManager.removeItem(at: backup)
- } catch {
- if fileManager.fileExists(atPath: destination.path) {
- try? fileManager.removeItem(at: destination)
- }
- if fileManager.fileExists(atPath: backup.path) {
- try? fileManager.moveItem(at: backup, to: destination)
- }
- throw error
- }
- }
-
- private static func validatedResponse(
- for request: RadrootsBackgroundTransferRequest, httpResult: RadrootsBackgroundHTTPResult
- ) throws
- -> RadrootsBackgroundTransferResponse
- {
- guard let statusCode = httpResult.statusCode else {
- throw RadrootsBackgroundResponseValidationError(failure: .responseMissing)
- }
- if request.responsePolicy == .discard {
- return try RadrootsBackgroundTransferResponse(
- statusCode: statusCode, mediaType: nil, contentEncoding: nil, body: nil)
- }
- guard let body = httpResult.body else {
- throw RadrootsBackgroundResponseValidationError(failure: .responseMissing)
- }
- guard let mediaType = httpResult.mediaType,
- request.responsePolicy.acceptedMediaTypes.contains(mediaType)
- else {
- throw RadrootsBackgroundResponseValidationError(failure: .responseMediaType)
- }
- guard body.count <= request.responsePolicy.maximumBodyBytes else {
- throw RadrootsBackgroundResponseValidationError(failure: .responseTooLarge)
- }
- guard httpResult.contentEncoding == nil || httpResult.contentEncoding == "identity" else {
- throw RadrootsBackgroundResponseValidationError(failure: .responseContentEncoding)
- }
- return try RadrootsBackgroundTransferResponse(
- statusCode: statusCode, mediaType: mediaType,
- contentEncoding: httpResult.contentEncoding, body: body)
- }
-
- private static func removeStagedDownload(
- _ result: RadrootsStagedBackgroundDownloadResult?, fileManager: FileManager
- ) {
- guard case .file(let url) = result, fileManager.fileExists(atPath: url.path) else { return }
- try? fileManager.removeItem(at: url)
- }
-}
-
-struct RadrootsBackgroundURLTaskDescriptor: Sendable, Equatable {
- private static let prefix = "radroots-transfer-v1"
-
- let identifier: RadrootsBackgroundTransferIdentifier
- let maximumTransferBytes: UInt64
- let maximumResponseBodyBytes: Int
- init(request: RadrootsBackgroundTransferRequest) {
- identifier = request.identifier
- maximumTransferBytes = request.maximumTransferBytes
- maximumResponseBodyBytes = request.responsePolicy.maximumBodyBytes
- }
-
- init?(taskDescription: String?) {
- guard let taskDescription else { return nil }
- let components = taskDescription.split(separator: "|", omittingEmptySubsequences: false)
- if components.count == 4,
- components[0] == Substring(Self.prefix),
- let identifier = try? RadrootsBackgroundTransferIdentifier(String(components[1])),
- let maximumTransferBytes = UInt64(components[2]),
- let maximumResponseBodyBytes = Int(components[3]),
- maximumTransferBytes > 0,
- maximumTransferBytes <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes,
- (0...65_536).contains(maximumResponseBodyBytes)
- {
- self.identifier = identifier
- self.maximumTransferBytes = maximumTransferBytes
- self.maximumResponseBodyBytes = maximumResponseBodyBytes
- return
- }
- guard let legacyIdentifier = try? RadrootsBackgroundTransferIdentifier(taskDescription) else {
- return nil
+ private func reserve(_ identifier: RadrootsBackgroundTransferIdentifier) throws {
+ guard admissions.count < 256, admissions.insert(identifier).inserted else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
}
- identifier = legacyIdentifier
- maximumTransferBytes = RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes
- maximumResponseBodyBytes = 65_536
- }
-
- var taskDescription: String {
- "\(Self.prefix)|\(identifier.rawValue)|\(maximumTransferBytes)|\(maximumResponseBodyBytes)"
- }
-}
-#if os(iOS)
- private actor RadrootsAppleBackgroundURLSession {
- private let identifier: String
- private let fileResolver: any RadrootsBackgroundTransferFileResolver
- private let downloadStagingRoot: URL
- private let coordinator: RadrootsAppleBackgroundTransferCoordinator
- private let fileManager: FileManager
- private let usesForegroundSession: Bool
- private var session: URLSession?
- private var sessionDelegate: RadrootsAppleBackgroundURLSessionDelegate?
- private var sessionDelegateQueue: OperationQueue?
-
- init(
- identifier: String, store: any RadrootsBackgroundTransferStore,
- fileResolver: any RadrootsBackgroundTransferFileResolver,
- downloadStagingRoot: URL, now: @escaping @Sendable () -> Date,
- usesForegroundSession: Bool = false
- ) {
- self.identifier = identifier
- self.fileResolver = fileResolver
- self.downloadStagingRoot = downloadStagingRoot
- fileManager = .default
- self.usesForegroundSession = usesForegroundSession
- coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: identifier, store: store, fileResolver: fileResolver, now: now
- )
+ private func release(_ identifier: RadrootsBackgroundTransferIdentifier) {
+ admissions.remove(identifier)
+ stoppedAdmissions.removeValue(forKey: identifier)
}
- func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws {
- let session = backgroundSession()
- var urlRequest = URLRequest(url: request.remoteURL)
- urlRequest.httpMethod = request.method.rawValue
- for (key, value) in request.headers {
- urlRequest.setValue(value, forHTTPHeaderField: key)
- }
- let task: URLSessionTask
- switch request.operation {
- case .download: task = session.downloadTask(with: urlRequest)
- case .upload(let source):
- let sourceURL = try fileResolver.resolve(source)
- let sourceData = try fileResolver.read(
- source,
- maximumBytes: Int(request.maximumTransferBytes)
+ private func schedule(
+ _ request: RadrootsBackgroundTransferRequest, replacing existing: RadrootsBackgroundTransferSnapshot?
+ ) async throws -> RadrootsBackgroundTransferHandle {
+ guard !Task.isCancelled, stoppedAdmissions[request.identifier] == nil else {
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
+ let queued = try RadrootsBackgroundTransferSnapshot(
+ request: request.redactedForPersistence(), updatedAt: adapters.now(), executionID: UUID()
)
- if let expectedDigest = request.expectedSourceSHA256,
- RadrootsAppleFileDigest.sha256(sourceData) != expectedDigest
- {
- throw RadrootsBackgroundTransferError.invalidRequest
+ guard try await exchange(existing, queued) else { throw RadrootsBackgroundTransferError.invalidRequest }
+ if Task.isCancelled {
+ stoppedAdmissions[request.identifier] = .cancelled
}
- task = session.uploadTask(with: urlRequest, fromFile: sourceURL)
- }
- task.taskDescription = RadrootsBackgroundURLTaskDescriptor(request: request).taskDescription
- sessionDelegate?.registerResponseBodyLimit(
- request.responsePolicy.maximumBodyBytes, taskIdentifier: task.taskIdentifier)
- task.resume()
- }
-
- func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async {
- let tasks = await allTasks()
- for task in tasks
- where RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier
- == identifier
- {
- task.cancel()
- }
- }
-
- func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> {
- let tasks = await allTasks()
- let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in
- RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier
- }
- return Set(identifiers)
- }
-
- func handleBackgroundEvents(
- identifier: String, completionHandler: @escaping @Sendable () -> Void
- ) async {
- _ = backgroundSession()
- await coordinator.handleBackgroundEvents(
- identifier: identifier, completionHandler: completionHandler)
- }
-
- private func allTasks() async -> [URLSessionTask] {
- await withCheckedContinuation { continuation in
- backgroundSession().getAllTasks { tasks in continuation.resume(returning: tasks) }
- }
- }
-
- private func backgroundSession() -> URLSession {
- if let session {
- return session
- }
- removeOrphanedDownloadStagingFiles()
- let configuration: URLSessionConfiguration
- if usesForegroundSession {
- configuration = .ephemeral
- } else {
- configuration = URLSessionConfiguration.background(withIdentifier: identifier)
- configuration.sessionSendsLaunchEvents = true
- configuration.isDiscretionary = false
- }
- let delegateQueue = OperationQueue()
- delegateQueue.name = "org.radroots.background-transfer.\(identifier)"
- delegateQueue.maxConcurrentOperationCount = 1
- let delegate = RadrootsAppleBackgroundURLSessionDelegate(
- coordinator: coordinator, downloadStagingRoot: downloadStagingRoot, fileManager: fileManager
- )
- let session = URLSession(
- configuration: configuration, delegate: delegate, delegateQueue: delegateQueue)
- self.session = session
- sessionDelegate = delegate
- sessionDelegateQueue = delegateQueue
- return session
- }
-
- private func removeOrphanedDownloadStagingFiles() {
- guard
- let urls = try? fileManager.contentsOfDirectory(
- at: downloadStagingRoot, includingPropertiesForKeys: nil)
- else { return }
- for url in urls where url.pathExtension == "download" {
- try? fileManager.removeItem(at: url)
- }
- }
- }
-
- private final class RadrootsAppleBackgroundURLSessionDelegate: NSObject,
- URLSessionDownloadDelegate, URLSessionDataDelegate,
- URLSessionTaskDelegate, @unchecked Sendable
- {
- private static let absoluteMaximumResponseBodyBytes = 65536
- private let coordinator: RadrootsAppleBackgroundTransferCoordinator
- private let downloadStagingRoot: URL
- private let fileManager: FileManager
- private let lock = NSLock()
- private var stagedDownloadResultsByTaskIdentifier: [Int: RadrootsStagedBackgroundDownloadResult]
- private var responseBodyLimitsByTaskIdentifier: [Int: Int]
- private var responseBodiesByTaskIdentifier: [Int: Data]
- private var exceededResponseBodyTaskIdentifiers: Set<Int>
-
- init(
- coordinator: RadrootsAppleBackgroundTransferCoordinator, downloadStagingRoot: URL,
- fileManager: FileManager
- ) {
- self.coordinator = coordinator
- self.downloadStagingRoot = downloadStagingRoot
- self.fileManager = fileManager
- stagedDownloadResultsByTaskIdentifier = [:]
- responseBodyLimitsByTaskIdentifier = [:]
- responseBodiesByTaskIdentifier = [:]
- exceededResponseBodyTaskIdentifiers = []
- }
-
- func urlSession(
- _: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL
- ) {
- guard let identifier = transferIdentifier(from: downloadTask) else { return }
- let result: RadrootsStagedBackgroundDownloadResult
- do {
- guard
- let descriptor = RadrootsBackgroundURLTaskDescriptor(
- taskDescription: downloadTask.taskDescription),
- let fileSize = try location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
- fileSize >= 0,
- UInt64(fileSize) <= descriptor.maximumTransferBytes
- else {
- throw RadrootsBackgroundTransferError.transferFailure
+ if let state = stoppedAdmissions[request.identifier] {
+ _ = try await exchange(queued, queued.transitioned(to: state, at: adapters.now(),
+ failure: state == .expired ? .expired : nil,
+ possibleRemoteOrphan: request.isUpload))
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
+ do {
+ guard let executionID = queued.executionID else { throw RadrootsBackgroundTransferError.invalidRequest }
+ try await adapters.enqueue(request, executionID)
+ } catch {
+ if stoppedAdmissions[request.identifier] != nil {
+ try? await adapters.cancel(request.identifier)
+ }
+ if let current = try await load(request.identifier), current.executionID == queued.executionID,
+ current.state == .queued || current.state == .running {
+ _ = try await exchange(current, current.transitioned(to: .failed, at: adapters.now(),
+ failure: .enqueueFailed,
+ possibleRemoteOrphan: request.isUpload))
+ }
+ throw RadrootsBackgroundTransferError.transferFailure
}
- try fileManager.createDirectory(at: downloadStagingRoot, withIntermediateDirectories: true)
- let destination = downloadStagingRoot.appendingPathComponent(
- "\(identifier.rawValue)-\(downloadTask.taskIdentifier).download"
- ).standardizedFileURL
- if fileManager.fileExists(atPath: destination.path) {
- try fileManager.removeItem(at: destination)
+ if stoppedAdmissions[request.identifier] != nil || Task.isCancelled {
+ try await stop(request.identifier, state: stoppedAdmissions[request.identifier] ?? .cancelled)
+ } else if let current = try await load(request.identifier), current.executionID == queued.executionID,
+ current.state == .queued {
+ _ = try await exchange(current, current.transitioned(to: .running, at: adapters.now()))
}
- try fileManager.moveItem(at: location, to: destination)
- try fileManager.setAttributes(
- [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
- ofItemAtPath: destination.path
- )
- result = .file(destination)
- } catch { result = .failure }
- recordDownloadResult(result, taskIdentifier: downloadTask.taskIdentifier)
- }
-
- func urlSession(
- _: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64,
- totalBytesWritten: Int64,
- totalBytesExpectedToWrite: Int64
- ) {
- guard let identifier = transferIdentifier(from: downloadTask) else { return }
- if exceedsTransferLimit(
- task: downloadTask,
- bytesTransferred: totalBytesWritten,
- totalBytesExpected: totalBytesExpectedToWrite
- ) {
- downloadTask.cancel()
- return
- }
- Task {
- await coordinator.updateProgress(
- identifier: identifier, bytesTransferred: totalBytesWritten,
- totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToWrite)
- )
- }
+ return RadrootsBackgroundTransferHandle(request: request)
}
- func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
- let shouldCancel = appendResponseBody(data, task: dataTask)
- if shouldCancel {
- dataTask.cancel()
- }
+ public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ try await stop(identifier, state: .cancelled)
}
- func urlSession(
- _: URLSession, task: URLSessionTask, didSendBodyData _: Int64, totalBytesSent: Int64,
- totalBytesExpectedToSend: Int64
- ) {
- guard let identifier = transferIdentifier(from: task) else { return }
- if exceedsTransferLimit(
- task: task, bytesTransferred: totalBytesSent, totalBytesExpected: totalBytesExpectedToSend)
- {
- task.cancel()
- return
- }
- Task {
- await coordinator.updateProgress(
- identifier: identifier, bytesTransferred: totalBytesSent,
- totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend)
- )
- }
+ public func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ try await stop(identifier, state: .expired)
}
- func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
- let bytesTransferred = max(max(task.countOfBytesReceived, task.countOfBytesSent), 0)
- let expected = Self.expectedByteCount(
- max(task.countOfBytesExpectedToReceive, task.countOfBytesExpectedToSend))
- let stagedDownloadResult = takeDownloadResult(taskIdentifier: task.taskIdentifier)
- let httpResult = takeHTTPResult(for: task)
- guard let identifier = transferIdentifier(from: task) else {
- if case .file(let url) = stagedDownloadResult {
- try? fileManager.removeItem(at: url)
+ private func stop(
+ _ identifier: RadrootsBackgroundTransferIdentifier,
+ state: RadrootsBackgroundTransferState
+ ) async throws {
+ if admissions.contains(identifier) {
+ stoppedAdmissions[identifier] = state
+ }
+ if let existing = try await load(identifier) {
+ guard [.queued, .running, .interrupted].contains(existing.state) else {
+ if admissions.contains(identifier), [.cancelled, .expired].contains(existing.state) {
+ do {
+ try await adapters.cancel(identifier)
+ } catch {
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
+ }
+ return
+ }
+ let desired = try existing.transitioned(to: state, at: adapters.now(),
+ failure: state == .expired ? .expired : nil,
+ possibleRemoteOrphan: existing.request.isUpload)
+ guard try await exchange(existing, desired) else {
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
+ }
+ do {
+ try await adapters.cancel(identifier)
+ } catch {
+ throw RadrootsBackgroundTransferError.transferFailure
}
- return
- }
- Task {
- await coordinator.complete(
- identifier: identifier, platformError: error, stagedDownloadResult: stagedDownloadResult,
- httpResult: httpResult,
- bytesTransferred: bytesTransferred, totalBytesExpected: expected
- )
- }
- }
-
- func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
- Task {
- await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier)
- }
- }
-
- func urlSession(
- _: URLSession,
- task _: URLSessionTask,
- willPerformHTTPRedirection _: HTTPURLResponse,
- newRequest _: URLRequest,
- completionHandler: @escaping (URLRequest?) -> Void
- ) {
- completionHandler(nil)
}
- private func recordDownloadResult(
- _ result: RadrootsStagedBackgroundDownloadResult, taskIdentifier: Int
- ) {
- lock.lock()
- defer { lock.unlock() }
- stagedDownloadResultsByTaskIdentifier[taskIdentifier] = result
+ public func settle(
+ _ identifier: RadrootsBackgroundTransferIdentifier, verification: RadrootsBackgroundTransferVerification
+ ) async throws {
+ guard let existing = try await load(identifier), existing.state == .awaitingVerification else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ let desired: RadrootsBackgroundTransferSnapshot = switch verification {
+ case .accepted:
+ try existing.transitioned(to: .completed, at: adapters.now())
+ case let .rejected(failure):
+ try existing.transitioned(to: .failed, at: adapters.now(), failure: failure,
+ possibleRemoteOrphan: existing.request.isUpload)
+ }
+ guard try await exchange(existing, desired) else { throw RadrootsBackgroundTransferError.transferFailure }
}
- private func takeDownloadResult(taskIdentifier: Int) -> RadrootsStagedBackgroundDownloadResult? {
- lock.lock()
- defer { lock.unlock() }
- return stagedDownloadResultsByTaskIdentifier.removeValue(forKey: taskIdentifier)
+ public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
+ -> RadrootsBackgroundTransferSnapshot? {
+ try await snapshots().first { $0.identifier == identifier }
}
- func registerResponseBodyLimit(_ limit: Int, taskIdentifier: Int) {
- lock.lock()
- defer { lock.unlock() }
- responseBodyLimitsByTaskIdentifier[taskIdentifier] = min(
- max(limit, 0), Self.absoluteMaximumResponseBodyBytes)
+ public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ let active = try await activeIdentifiers()
+ let stored = try await loadAll()
+ let identifiers = Set(stored.map(\.identifier))
+ for orphan in active.subtracting(identifiers) where !admissions.contains(orphan) {
+ guard try await !admissionIsActive(orphan) else { continue }
+ do {
+ try await adapters.cancel(orphan)
+ } catch {
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
+ }
+ for existing in stored where !admissions.contains(existing.identifier) {
+ guard try await !admissionIsActive(existing.identifier) else { continue }
+ let desired: RadrootsBackgroundTransferSnapshot
+ if active.contains(existing.identifier), [.queued, .interrupted].contains(existing.state) {
+ desired = try existing.transitioned(to: .running, at: adapters.now(), failure: existing.failure)
+ } else if !active.contains(existing.identifier), [.queued, .running].contains(existing.state) {
+ desired = try existing.transitioned(to: .interrupted, at: adapters.now(), failure: .interrupted,
+ possibleRemoteOrphan: existing.request.isUpload)
+ } else {
+ continue
+ }
+ _ = try await exchange(existing, desired)
+ }
+ return try await loadAll()
}
- private func appendResponseBody(_ data: Data, task: URLSessionDataTask) -> Bool {
- lock.lock()
- defer { lock.unlock() }
- let taskIdentifier = task.taskIdentifier
- guard !exceededResponseBodyTaskIdentifiers.contains(taskIdentifier) else { return false }
- let limit =
- responseBodyLimitsByTaskIdentifier[taskIdentifier]
- ?? RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?
- .maximumResponseBodyBytes
- ?? Self.absoluteMaximumResponseBodyBytes
- guard limit > 0 else { return false }
- let currentCount = responseBodiesByTaskIdentifier[taskIdentifier]?.count ?? 0
- guard data.count <= limit - currentCount else {
- responseBodiesByTaskIdentifier.removeValue(forKey: taskIdentifier)
- exceededResponseBodyTaskIdentifiers.insert(taskIdentifier)
- return true
- }
- responseBodiesByTaskIdentifier[taskIdentifier, default: Data()].append(data)
- return false
+ public func handleEventsForBackgroundURLSession(
+ identifier: String, completionHandler: @escaping @Sendable () -> Void
+ ) async {
+ await adapters.handleBackgroundEvents(identifier, completionHandler)
}
- private func takeHTTPResult(for task: URLSessionTask) -> RadrootsBackgroundHTTPResult {
- lock.lock()
- let body = responseBodiesByTaskIdentifier.removeValue(forKey: task.taskIdentifier)
- responseBodyLimitsByTaskIdentifier.removeValue(forKey: task.taskIdentifier)
- let exceeded = exceededResponseBodyTaskIdentifiers.remove(task.taskIdentifier) != nil
- lock.unlock()
-
- guard let response = task.response as? HTTPURLResponse else {
- return RadrootsBackgroundHTTPResult(
- statusCode: nil, mediaType: nil, body: body, bodyExceeded: exceeded)
- }
- let rawMediaType = response.value(forHTTPHeaderField: "Content-Type")
- let mediaType = rawMediaType.flatMap {
- try? RadrootsBackgroundTransferValidation.normalizedMediaType($0)
- }
- let contentEncoding = response.value(forHTTPHeaderField: "Content-Encoding")?
- .trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- return RadrootsBackgroundHTTPResult(
- statusCode: response.statusCode, mediaType: mediaType, body: body,
- contentEncoding: contentEncoding, bodyExceeded: exceeded,
- mediaTypeWasMalformed: rawMediaType != nil && mediaType == nil)
+ private func load(_ identifier: RadrootsBackgroundTransferIdentifier) async throws
+ -> RadrootsBackgroundTransferSnapshot? {
+ try await loadAll().first { $0.identifier == identifier }
}
- private func transferIdentifier(from task: URLSessionTask)
- -> RadrootsBackgroundTransferIdentifier?
- {
- RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier
+ private func loadAll() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ do {
+ return try await store.loadSnapshots()
+ } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
}
- private func exceedsTransferLimit(
- task: URLSessionTask, bytesTransferred: Int64, totalBytesExpected: Int64
- ) -> Bool {
- guard
- let descriptor = RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)
- else { return true }
- return bytesTransferred > 0 && UInt64(bytesTransferred) > descriptor.maximumTransferBytes
- || totalBytesExpected > 0 && UInt64(totalBytesExpected) > descriptor.maximumTransferBytes
+ private func exchange(
+ _ expected: RadrootsBackgroundTransferSnapshot?, _ desired: RadrootsBackgroundTransferSnapshot
+ ) async throws -> Bool {
+ do {
+ return try await store.compareExchangeSnapshot(expected: expected, desired: desired)
+ } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
}
- private static func expectedByteCount(_ value: Int64) -> Int64? {
- value >= 0 ? value : nil
+ private func activeIdentifiers() async throws -> Set<RadrootsBackgroundTransferIdentifier> {
+ do {
+ return try await adapters.activeTransferIdentifiers()
+ } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch {
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
}
- }
-#endif
-extension RadrootsBackgroundTransferRequest {
- fileprivate var isUpload: Bool {
- if case .upload = operation {
- return true
+ private func admissionIsActive(_ identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool {
+ do {
+ return try await store.admissionIsActive(for: identifier)
+ } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
}
- return false
- }
-}
-
-private struct RadrootsBackgroundResponseValidationError: Error {
- let failure: RadrootsBackgroundTransferFailure
}
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferAdapters.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferAdapters.swift
@@ -0,0 +1,92 @@
+import Foundation
+
+public struct RadrootsAppleBackgroundTransferAdapters: Sendable {
+ public let now: @Sendable () -> Date
+ public let enqueue: @Sendable (RadrootsBackgroundTransferRequest, UUID) async throws -> Void
+ public let cancel: @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void
+ public let activeTransferIdentifiers: @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>
+ public let handleBackgroundEvents: @Sendable (String, @escaping @Sendable () -> Void) async -> Void
+
+ public init(
+ now: @escaping @Sendable () -> Date = Date.init,
+ enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest, UUID) async throws -> Void,
+ cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void,
+ activeTransferIdentifiers:
+ @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>,
+ handleBackgroundEvents:
+ @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void
+ ) {
+ self.now = now
+ self.enqueue = enqueue
+ self.cancel = cancel
+ self.activeTransferIdentifiers = activeTransferIdentifiers
+ self.handleBackgroundEvents = handleBackgroundEvents
+ }
+
+ public static let unavailable = Self(
+ enqueue: { _, _ in
+ throw RadrootsBackgroundTransferError.unavailable
+ },
+ cancel: { _ in
+ throw RadrootsBackgroundTransferError.unavailable
+ },
+ activeTransferIdentifiers: {
+ throw RadrootsBackgroundTransferError.unavailable
+ }, handleBackgroundEvents: { _, completionHandler in completionHandler() }
+ )
+
+ public static func live(
+ sessionIdentifier: String, store: any RadrootsBackgroundTransferStore,
+ fileResolver: any RadrootsBackgroundTransferFileResolver,
+ downloadStagingRoot: URL, now: @escaping @Sendable () -> Date = Date.init
+ ) throws -> Self {
+ #if os(iOS)
+ let normalizedSessionIdentifier =
+ try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier)
+ let session = RadrootsAppleBackgroundURLSession(
+ identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver,
+ downloadStagingRoot: downloadStagingRoot,
+ now: now
+ )
+ #if targetEnvironment(simulator)
+ let simulatorSession = RadrootsAppleBackgroundURLSession(
+ identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver,
+ downloadStagingRoot: downloadStagingRoot,
+ now: now, usesForegroundSession: true
+ )
+ #endif
+ return Self(
+ now: now,
+ enqueue: { request, executionID in
+ #if targetEnvironment(simulator)
+ if request.networkPolicy == .simulatorLoopbackHTTP {
+ try await simulatorSession.enqueue(request, executionID: executionID)
+ return
+ }
+ #endif
+ try await session.enqueue(request, executionID: executionID)
+ },
+ cancel: { identifier in
+ #if targetEnvironment(simulator)
+ await simulatorSession.cancel(identifier)
+ #endif
+ await session.cancel(identifier)
+ },
+ activeTransferIdentifiers: {
+ var identifiers = await session.activeTransferIdentifiers()
+ #if targetEnvironment(simulator)
+ await identifiers.formUnion(simulatorSession.activeTransferIdentifiers())
+ #endif
+ return identifiers
+ },
+ handleBackgroundEvents: { identifier, completionHandler in
+ await session.handleBackgroundEvents(
+ identifier: identifier, completionHandler: completionHandler
+ )
+ }
+ )
+ #else
+ return .unavailable
+ #endif
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferCoordinator.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferCoordinator.swift
@@ -0,0 +1,321 @@
+import Foundation
+
+actor RadrootsTransferCoordinator {
+ private let sessionIdentifier: String
+ private let store: any RadrootsBackgroundTransferStore
+ private let fileResolver: any RadrootsBackgroundTransferFileResolver
+ private let now: @Sendable () -> Date
+ private let fileManager: FileManager
+ private var completionHandlers: [@Sendable () -> Void]
+ private var unclaimedFinishedEventCount: Int
+
+ init(
+ sessionIdentifier: String, store: any RadrootsBackgroundTransferStore,
+ fileResolver: any RadrootsBackgroundTransferFileResolver,
+ now: @escaping @Sendable () -> Date = Date.init, fileManager: FileManager = .default
+ ) {
+ self.sessionIdentifier = sessionIdentifier
+ self.store = store
+ self.fileResolver = fileResolver
+ self.now = now
+ self.fileManager = fileManager
+ completionHandlers = []
+ unclaimedFinishedEventCount = 0
+ }
+
+ func updateProgress(
+ identifier: RadrootsBackgroundTransferIdentifier, bytesTransferred: Int64,
+ totalBytesExpected: Int64?, executionID: UUID? = nil
+ ) async {
+ guard let existing = try? await snapshot(for: identifier), existing.executionID == executionID,
+ existing.state == .running || existing.state == .queued
+ else { return }
+ guard
+ let progress = Self.progress(
+ bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected,
+ fallback: existing.progress
+ )
+ else { return }
+ _ = try? await store.compareExchangeSnapshot(expected: existing, desired:
+ try RadrootsBackgroundTransferSnapshot(
+ request: existing.request, state: .running, progress: progress,
+ failure: existing.failure,
+ response: existing.response, possibleRemoteOrphan: existing.possibleRemoteOrphan,
+ updatedAt: now(), executionID: existing.executionID, uploadLease: existing.uploadLease
+ ))
+ }
+
+ func complete(
+ identifier: RadrootsBackgroundTransferIdentifier, completion: RadrootsTransferCompletion,
+ executionID: UUID? = nil
+ ) async {
+ guard let existing = try? await snapshot(for: identifier), existing.executionID == executionID,
+ [.queued, .running, .interrupted].contains(existing.state)
+ else { return }
+ if let failure = Self.completionFailure(request: existing.request, completion: completion) {
+ Self.removeStagedDownload(completion.stagedDownloadResult, fileManager: fileManager)
+ await fail(existing: existing, code: failure, possibleRemoteOrphan: existing.request.isUpload)
+ return
+ }
+ let response: RadrootsBackgroundTransferResponse
+ do {
+ response = try Self.validatedResponse(for: existing.request, httpResult: completion.httpResult)
+ } catch {
+ Self.removeStagedDownload(completion.stagedDownloadResult, fileManager: fileManager)
+ await fail(existing: existing,
+ code: (error as? RadrootsTransferResponseError)?.failure ?? .responseInvalid,
+ possibleRemoteOrphan: existing.request.isUpload)
+ return
+ }
+ switch existing.request.operation {
+ case let .download(destination):
+ await completeDownload(
+ existing: existing,
+ destination: destination,
+ response: response,
+ completion: completion
+ )
+ case .upload:
+ await completeUpload(existing: existing, response: response, bytesTransferred: completion.bytesTransferred,
+ totalBytesExpected: completion.totalBytesExpected)
+ }
+ }
+
+ private static func completionFailure(
+ request: RadrootsBackgroundTransferRequest, completion: RadrootsTransferCompletion
+ ) -> RadrootsBackgroundTransferFailure? {
+ if UInt64(max(completion.bytesTransferred, 0)) > request.maximumTransferBytes
+ || completion.totalBytesExpected.map({ UInt64(max($0, 0)) > request.maximumTransferBytes }) == true {
+ return .transferTooLarge
+ }
+ if completion.httpResult.bodyExceeded {
+ return .responseTooLarge
+ }
+ if completion.httpResult.mediaTypeWasMalformed {
+ return .responseMediaType
+ }
+ if completion.platformError != nil {
+ return .platformFailure
+ }
+ guard let status = completion.httpResult.statusCode else { return .responseMissing }
+ return (200 ... 299).contains(status) ? nil : .httpStatus
+ }
+
+ func releaseUploadLease(executionID: UUID?) {
+ guard let executionID else { return }
+ // Only the native terminal callback calls this. Cancellation intent alone
+ // cannot release bytes still owned by URLSession. Failed cleanup remains
+ // an owned orphan for later reconciliation.
+ try? fileResolver.releaseUploadLease(executionID: executionID)
+ }
+
+ func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) {
+ guard identifier == sessionIdentifier else {
+ completionHandler()
+ return
+ }
+ if unclaimedFinishedEventCount > 0 {
+ unclaimedFinishedEventCount -= 1
+ completionHandler()
+ return
+ }
+ guard completionHandlers.count < 8 else {
+ completionHandler()
+ return
+ }
+ completionHandlers.append(completionHandler)
+ }
+
+ func finishBackgroundEvents(identifier: String?) {
+ guard identifier == nil || identifier == sessionIdentifier else { return }
+ guard !completionHandlers.isEmpty else {
+ unclaimedFinishedEventCount = min(unclaimedFinishedEventCount + 1, 8)
+ return
+ }
+ let handlers = completionHandlers
+ completionHandlers.removeAll()
+ for handler in handlers {
+ handler()
+ }
+ }
+}
+
+extension RadrootsTransferCoordinator {
+ private func completeUpload(
+ existing: RadrootsBackgroundTransferSnapshot, response: RadrootsBackgroundTransferResponse,
+ bytesTransferred: Int64,
+ totalBytesExpected: Int64?
+ ) async {
+ let progress =
+ Self.progress(
+ bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected,
+ fallback: existing.progress
+ )
+ ?? existing.progress
+ _ = try? await store.compareExchangeSnapshot(expected: existing, desired:
+ try RadrootsBackgroundTransferSnapshot(
+ request: existing.request, state: .awaitingVerification, progress: progress,
+ response: response, updatedAt: now(), executionID: existing.executionID,
+ uploadLease: existing.uploadLease
+ ))
+ }
+
+ private func completeDownload(
+ existing: RadrootsBackgroundTransferSnapshot, destination: RadrootsBackgroundTransferLocalFile,
+ response: RadrootsBackgroundTransferResponse, completion: RadrootsTransferCompletion
+ ) async {
+ let stagedDownloadResult = completion.stagedDownloadResult
+ let mediaType = completion.httpResult.mediaType
+ let bytesTransferred = completion.bytesTransferred
+ let totalBytesExpected = completion.totalBytesExpected
+ guard case let .file(stagedFileURL) = stagedDownloadResult else {
+ await fail(existing: existing, code: .downloadStagingFailure)
+ return
+ }
+ do {
+ guard case let .file(destinationReference) = destination else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ let destinationURL = try fileResolver.resolve(destination)
+ let fileSize = try Self.fileSize(at: stagedFileURL, fileManager: fileManager)
+ guard fileSize > 0, UInt64(fileSize) <= existing.request.maximumTransferBytes else {
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
+ let downloadedArtifact = try RadrootsBackgroundDownloadedArtifact(
+ file: destinationReference,
+ sha256: RadrootsAppleFileDigest.sha256(at: stagedFileURL),
+ byteSize: UInt64(fileSize),
+ mediaType: mediaType
+ )
+ try fileManager.createDirectory(
+ at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true
+ )
+ try Self.moveReplacingItem(from: stagedFileURL, to: destinationURL, fileManager: fileManager)
+ #if os(iOS)
+ try fileManager.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
+ ofItemAtPath: destinationURL.path
+ )
+ #endif
+ let progress =
+ Self.progress(
+ bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected,
+ fallback: existing.progress
+ )
+ ?? existing.progress
+ _ = try await store.compareExchangeSnapshot(expected: existing, desired:
+ RadrootsBackgroundTransferSnapshot(
+ request: existing.request, state: .awaitingVerification, progress: progress,
+ response: response,
+ downloadedArtifact: downloadedArtifact, updatedAt: now(), executionID: existing.executionID,
+ uploadLease: existing.uploadLease
+ ))
+ } catch {
+ Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager)
+ await fail(existing: existing, code: .destinationFailure)
+ }
+ }
+
+ private func fail(
+ existing: RadrootsBackgroundTransferSnapshot,
+ code: RadrootsBackgroundTransferFailure,
+ possibleRemoteOrphan: Bool = false
+ ) async {
+ _ = try? await store.compareExchangeSnapshot(expected: existing, desired:
+ try RadrootsBackgroundTransferSnapshot(
+ request: existing.request, state: .failed, progress: existing.progress, failure: code,
+ possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: now(), executionID: existing.executionID,
+ uploadLease: existing.uploadLease
+ ))
+ }
+
+ private func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
+ -> RadrootsBackgroundTransferSnapshot? {
+ try await store.loadSnapshots().first { $0.identifier == identifier }
+ }
+
+ private static func progress(
+ bytesTransferred: Int64, totalBytesExpected: Int64?,
+ fallback: RadrootsBackgroundTransferProgress
+ )
+ -> RadrootsBackgroundTransferProgress? {
+ let safeBytesTransferred = max(bytesTransferred, fallback.bytesTransferred)
+ let safeTotalBytesExpected =
+ totalBytesExpected.flatMap { value -> Int64? in value >= safeBytesTransferred ? value : nil }
+ ?? fallback.totalBytesExpected.flatMap { value -> Int64? in
+ value >= safeBytesTransferred ? value : nil
+ }
+ return try? RadrootsBackgroundTransferProgress(
+ bytesTransferred: safeBytesTransferred, totalBytesExpected: safeTotalBytesExpected
+ )
+ }
+
+ private static func fileSize(at url: URL, fileManager _: FileManager) throws -> Int64 {
+ let values = try url.resourceValues(forKeys: [.fileSizeKey])
+ return Int64(values.fileSize ?? 0)
+ }
+
+ private static func moveReplacingItem(
+ from source: URL, to destination: URL, fileManager: FileManager
+ ) throws {
+ guard fileManager.fileExists(atPath: destination.path) else {
+ try fileManager.moveItem(at: source, to: destination)
+ return
+ }
+ let backup = destination.deletingLastPathComponent().appendingPathComponent(
+ ".radroots-transfer-backup-\(UUID().uuidString.lowercased())"
+ )
+ try fileManager.moveItem(at: destination, to: backup)
+ do {
+ try fileManager.moveItem(at: source, to: destination)
+ try fileManager.removeItem(at: backup)
+ } catch {
+ if fileManager.fileExists(atPath: destination.path) {
+ try? fileManager.removeItem(at: destination)
+ }
+ if fileManager.fileExists(atPath: backup.path) {
+ try? fileManager.moveItem(at: backup, to: destination)
+ }
+ throw error
+ }
+ }
+
+ private static func validatedResponse(
+ for request: RadrootsBackgroundTransferRequest, httpResult: RadrootsBackgroundHTTPResult
+ ) throws
+ -> RadrootsBackgroundTransferResponse {
+ guard let statusCode = httpResult.statusCode else {
+ throw RadrootsTransferResponseError(failure: .responseMissing)
+ }
+ if request.responsePolicy == .discard {
+ return try RadrootsBackgroundTransferResponse(
+ statusCode: statusCode, mediaType: nil, contentEncoding: nil, body: nil
+ )
+ }
+ guard let body = httpResult.body else {
+ throw RadrootsTransferResponseError(failure: .responseMissing)
+ }
+ guard let mediaType = httpResult.mediaType,
+ request.responsePolicy.acceptedMediaTypes.contains(mediaType)
+ else {
+ throw RadrootsTransferResponseError(failure: .responseMediaType)
+ }
+ guard body.count <= request.responsePolicy.maximumBodyBytes else {
+ throw RadrootsTransferResponseError(failure: .responseTooLarge)
+ }
+ guard httpResult.contentEncoding == nil || httpResult.contentEncoding == "identity" else {
+ throw RadrootsTransferResponseError(failure: .responseContentEncoding)
+ }
+ return try RadrootsBackgroundTransferResponse(
+ statusCode: statusCode, mediaType: mediaType,
+ contentEncoding: httpResult.contentEncoding, body: body
+ )
+ }
+
+ private static func removeStagedDownload(
+ _ result: RadrootsStagedBackgroundDownloadResult?, fileManager: FileManager
+ ) {
+ guard case let .file(url) = result, fileManager.fileExists(atPath: url.path) else { return }
+ try? fileManager.removeItem(at: url)
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferFileResolver.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferFileResolver.swift
@@ -0,0 +1,88 @@
+import Foundation
+
+public struct RadrootsAppleBackgroundTransferFileResolver: RadrootsBackgroundTransferFileResolver,
+ Sendable {
+ private let roots: RadrootsAppleFileRoots
+
+ public init(roots: RadrootsAppleFileRoots) {
+ self.roots = roots
+ }
+
+ public func prepareUploadLease(
+ for request: RadrootsBackgroundTransferRequest, executionID: UUID,
+ existing: RadrootsStagedBlobReference?
+ ) throws -> RadrootsStagedBlobLease {
+ guard case let .upload(source) = request.operation else { throw RadrootsBackgroundTransferError.invalidRequest }
+ let owner = RadrootsAppleFileAccess(roots: roots)
+ let identifier = executionID.uuidString.lowercased()
+ if let existing {
+ guard request.expectedSourceSHA256 == nil || request.expectedSourceSHA256 == existing.blobID else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ return try owner.leaseStagedBlob(existing, expectedSHA256: existing.blobID, identifier: identifier)
+ }
+ let bytes = try read(source, maximumBytes: Int(request.maximumTransferBytes))
+ let digest = RadrootsAppleFileDigest.sha256(bytes)
+ guard request.expectedSourceSHA256 == nil || request.expectedSourceSHA256 == digest else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ return try owner.leaseUploadBytes(bytes, identifier: identifier)
+ }
+
+ public func releaseUploadLease(executionID: UUID) throws {
+ try RadrootsAppleFileAccess(roots: roots).releaseUploadLease(executionID: executionID)
+ }
+
+ public func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL {
+ let candidate: URL
+ let root: URL
+ switch file {
+ case let .file(reference):
+ candidate = try roots.resolvedURL(for: reference)
+ root = roots.root(for: reference.scope)
+ case let .stagedBlob(blob):
+ candidate = try roots.stagedBlobURL(for: blob)
+ root = roots.stagedBlobsRoot
+ }
+ let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL
+ let resolvedCandidate = candidate.resolvingSymlinksInPath().standardizedFileURL
+ guard resolvedCandidate.path.hasPrefix(resolvedRoot.path + "/") else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ return candidate
+ }
+
+ public func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data {
+ guard maximumBytes >= 0 else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ let root: URL
+ let relativePath: String
+ let expectedBytes: Int?
+ switch file {
+ case let .file(reference):
+ root = roots.root(for: reference.scope)
+ relativePath = reference.relativePath
+ expectedBytes = nil
+ case let .stagedBlob(blob):
+ root = roots.stagedBlobsRoot
+ relativePath = blob.blobID
+ expectedBytes = blob.sizeBytes
+ }
+ do {
+ let data = try RadrootsGovernedFileReader.read(
+ root: root,
+ relativePath: relativePath,
+ maximumBytes: maximumBytes
+ )
+ guard expectedBytes == nil || expectedBytes == data.count else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ return data
+ } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferStore.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferStore.swift
@@ -0,0 +1,234 @@
+import Darwin
+import Foundation
+
+public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore {
+ private static let maximumPersistenceBytes = 1024 * 1024
+
+ private struct Envelope: Codable {
+ let schemaVersion: Int
+ let snapshots: [RadrootsBackgroundTransferSnapshot]
+
+ init(snapshots: [RadrootsBackgroundTransferSnapshot]) {
+ schemaVersion = 1
+ self.snapshots = snapshots
+ }
+ }
+
+ private let roots: RadrootsAppleFileRoots
+ private let fileManager: FileManager
+ private let encoder: JSONEncoder
+ private let decoder: JSONDecoder
+ private let protectedData: RadrootsProtectedDataProvider
+
+ public init(
+ roots: RadrootsAppleFileRoots, fileManager: FileManager = .default,
+ protectedData: RadrootsProtectedDataProvider = .available
+ ) {
+ self.roots = roots
+ self.fileManager = fileManager
+ encoder = JSONEncoder()
+ decoder = JSONDecoder()
+ self.protectedData = protectedData
+ encoder.outputFormatting = [.sortedKeys]
+ }
+
+ public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ try withStoreLock { try loadSnapshotsSynchronously() }
+ }
+
+ public func withAdmission(
+ for identifier: RadrootsBackgroundTransferIdentifier,
+ operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle
+ ) async throws -> RadrootsBackgroundTransferHandle {
+ guard let descriptor = try acquireAdmission(identifier) else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ // Only this attempt owns the descriptor across awaits. Persistence uses a
+ // separate short lock; neither reservation waits for another owner.
+ defer { Darwin.close(descriptor) }
+ return try await operation()
+ }
+
+ public func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool {
+ guard let descriptor = try acquireAdmission(identifier) else { return true }
+ Darwin.close(descriptor)
+ return false
+ }
+
+ private func acquireAdmission(_ identifier: RadrootsBackgroundTransferIdentifier) throws -> Int32? {
+ try requireProtectedData()
+ do {
+ let name = RadrootsAppleFileDigest.sha256(Data(identifier.rawValue.utf8))
+ let url = try roots.resolvedURL(for: RadrootsFileReference(
+ scope: .data, relativePath: "background_transfers/admissions/\(name).lock"
+ ))
+ return try RadrootsAtomicFile.acquireExclusiveLock(at: url)
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ }
+
+ public func compareExchangeSnapshot(
+ expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot
+ ) async throws -> Bool {
+ try withStoreLock {
+ guard expected == nil || expected?.identifier == desired.identifier else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ var snapshots = try loadSnapshotsSynchronously()
+ let current = snapshots.first { $0.identifier == desired.identifier }
+ guard try current == (expected?.redactedForPersistence()) else { return false }
+ snapshots.removeAll { $0.identifier == desired.identifier }
+ try snapshots.append(desired.redactedForPersistence())
+ try write(snapshots.sorted { $0.identifier < $1.identifier })
+ return true
+ }
+ }
+
+ private func withStoreLock<T>(_ body: () throws -> T) throws -> T {
+ try requireProtectedData()
+ do {
+ let lockURL = try roots.resolvedURL(
+ for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.lock")
+ )
+ return try RadrootsAtomicFile.withExclusiveLock(at: lockURL, body)
+ } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ }
+
+ private func loadSnapshotsSynchronously() throws -> [RadrootsBackgroundTransferSnapshot] {
+ try requireProtectedData()
+ do {
+ let url = try storeURL()
+ let legacyURL = try legacyStoreURL()
+ let isLegacy: Bool
+ if fileManager.fileExists(atPath: url.path) {
+ isLegacy = false
+ } else if fileManager.fileExists(atPath: legacyURL.path) {
+ isLegacy = true
+ } else {
+ return []
+ }
+ let data = try RadrootsGovernedFileReader.read(
+ root: roots.root(for: isLegacy ? .cache : .data),
+ relativePath: "background_transfers/transfers.json",
+ maximumBytes: Self.maximumPersistenceBytes
+ )
+ let decoded: [RadrootsBackgroundTransferSnapshot]
+ let usedLegacyEncoding: Bool
+ if let envelope = try? decoder.decode(Envelope.self, from: data) {
+ guard envelope.schemaVersion == 1 else {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ decoded = envelope.snapshots
+ usedLegacyEncoding = false
+ } else {
+ decoded = try decoder.decode([RadrootsBackgroundTransferSnapshot].self, from: data)
+ usedLegacyEncoding = true
+ }
+ let snapshots = try decoded.map { try $0.redactedForPersistence() }.sorted { left, right in
+ left.identifier < right.identifier
+ }
+ if isLegacy || usedLegacyEncoding {
+ try write(snapshots)
+ }
+ if fileManager.fileExists(atPath: legacyURL.path) {
+ try fileManager.removeItem(at: legacyURL)
+ }
+ return snapshots
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ }
+
+ public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws {
+ try withStoreLock { try saveSnapshotSynchronously(snapshot) }
+ }
+
+ private func saveSnapshotSynchronously(_ snapshot: RadrootsBackgroundTransferSnapshot) throws {
+ try requireProtectedData()
+ do {
+ var snapshots = try loadSnapshotsSynchronously()
+ snapshots.removeAll { $0.identifier == snapshot.identifier }
+ try snapshots.append(snapshot.redactedForPersistence())
+ try write(snapshots.sorted { left, right in left.identifier < right.identifier })
+ } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ }
+
+ public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ try withStoreLock { try removeSnapshotSynchronously(for: identifier) }
+ }
+
+ private func removeSnapshotSynchronously(for identifier: RadrootsBackgroundTransferIdentifier) throws {
+ try requireProtectedData()
+ do {
+ var snapshots = try loadSnapshotsSynchronously()
+ snapshots.removeAll { $0.identifier == identifier }
+ try write(snapshots)
+ } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ }
+
+ public func removeAllSnapshots() async throws {
+ try withStoreLock { try removeAllSnapshotsSynchronously() }
+ }
+
+ private func removeAllSnapshotsSynchronously() throws {
+ try requireProtectedData()
+ do {
+ for url in try [storeURL(), legacyStoreURL()] where fileManager.fileExists(atPath: url.path) {
+ try fileManager.removeItem(at: url)
+ }
+ } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ }
+
+ private func write(_ snapshots: [RadrootsBackgroundTransferSnapshot]) throws {
+ let url = try storeURL()
+ try fileManager.createDirectory(
+ at: url.deletingLastPathComponent(), withIntermediateDirectories: true
+ )
+ let data = try encoder.encode(Envelope(snapshots: snapshots))
+ guard data.count <= Self.maximumPersistenceBytes else {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ try RadrootsAtomicFile.install(data, at: url)
+ #if os(iOS)
+ try fileManager.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
+ ofItemAtPath: url.path
+ )
+ #endif
+ }
+
+ private func storeURL() throws -> URL {
+ try roots.resolvedURL(
+ for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.json")
+ )
+ }
+
+ private func legacyStoreURL() throws -> URL {
+ try roots.resolvedURL(
+ for: RadrootsFileReference(scope: .cache, relativePath: "background_transfers/transfers.json")
+ )
+ }
+
+ private func requireProtectedData() throws {
+ guard protectedData.currentState() == .available else {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift
@@ -0,0 +1,175 @@
+import Foundation
+
+#if os(iOS)
+
+ actor RadrootsAppleBackgroundURLSession {
+ private let identifier: String
+ private let fileResolver: any RadrootsBackgroundTransferFileResolver
+ private let downloadStagingRoot: URL
+ private let coordinator: RadrootsTransferCoordinator
+ private let fileManager: FileManager
+ private let usesForegroundSession: Bool
+ private var session: URLSession?
+ private var sessionDelegate: RadrootsTransferSessionDelegate?
+ private var sessionDelegateQueue: OperationQueue?
+ private let store: any RadrootsBackgroundTransferStore
+ private var admissions: Set<RadrootsBackgroundTransferIdentifier> = []
+ private var cancelledAdmissions: Set<RadrootsBackgroundTransferIdentifier> = []
+
+ init(
+ identifier: String, store: any RadrootsBackgroundTransferStore,
+ fileResolver: any RadrootsBackgroundTransferFileResolver,
+ downloadStagingRoot: URL, now: @escaping @Sendable () -> Date,
+ usesForegroundSession: Bool = false
+ ) {
+ self.identifier = identifier
+ self.store = store
+ self.fileResolver = fileResolver
+ self.downloadStagingRoot = downloadStagingRoot
+ fileManager = .default
+ self.usesForegroundSession = usesForegroundSession
+ coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: identifier, store: store, fileResolver: fileResolver, now: now
+ )
+ }
+
+ func enqueue(_ request: RadrootsBackgroundTransferRequest, executionID: UUID) async throws {
+ guard admissions.count < 256, admissions.insert(request.identifier).inserted else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ defer {
+ admissions.remove(request.identifier)
+ cancelledAdmissions.remove(request.identifier)
+ }
+ if try await alreadyAdmitted(request.identifier, executionID: executionID) {
+ return
+ }
+ guard let snapshot = try await store.loadSnapshots().first(where: { $0.identifier == request.identifier }),
+ snapshot.executionID == executionID, snapshot.state == .queued,
+ try snapshot.request == request.redactedForPersistence(),
+ !cancelledAdmissions.contains(request.identifier)
+ else { throw RadrootsBackgroundTransferError.invalidRequest }
+ let session = backgroundSession()
+ var urlRequest = URLRequest(url: request.remoteURL)
+ urlRequest.httpMethod = request.method.rawValue
+ for (key, value) in request.headers {
+ urlRequest.setValue(value, forHTTPHeaderField: key)
+ }
+ let task: URLSessionTask
+ switch request.operation {
+ case .download: task = session.downloadTask(with: urlRequest)
+ case .upload:
+ let lease = try fileResolver.prepareUploadLease(
+ for: request,
+ executionID: executionID,
+ existing: snapshot.uploadLease
+ )
+ let leased = try RadrootsBackgroundTransferSnapshot(
+ request: snapshot.request, state: .queued, updatedAt: snapshot.updatedAt,
+ executionID: executionID, uploadLease: lease.blob
+ )
+ guard try await store.compareExchangeSnapshot(expected: snapshot, desired: leased),
+ !cancelledAdmissions.contains(request.identifier), !Task.isCancelled
+ else { throw RadrootsBackgroundTransferError.transferFailure }
+ task = session.uploadTask(with: urlRequest, fromFile: lease.fileURL)
+ }
+ task.taskDescription = RadrootsBackgroundURLTaskDescriptor(request: request, executionID: executionID)
+ .taskDescription
+ sessionDelegate?.registerResponseBodyLimit(
+ request.responsePolicy.maximumBodyBytes, taskIdentifier: task.taskIdentifier
+ )
+ task.resume()
+ }
+
+ private func alreadyAdmitted(_ identifier: RadrootsBackgroundTransferIdentifier,
+ executionID: UUID) async throws -> Bool {
+ let tasks = await allTasks()
+ let existingTasks = tasks.filter {
+ RadrootsBackgroundURLTaskDescriptor(taskDescription: $0.taskDescription)?.identifier == identifier
+ }
+ if !existingTasks.isEmpty {
+ guard existingTasks.count == 1,
+ RadrootsBackgroundURLTaskDescriptor(taskDescription: existingTasks[0].taskDescription)?
+ .executionID == executionID
+ else { throw RadrootsBackgroundTransferError.invalidRequest }
+ return true
+ }
+ return false
+ }
+
+ func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async {
+ if admissions.contains(identifier) {
+ cancelledAdmissions.insert(identifier)
+ }
+ let tasks = await allTasks()
+ for task in tasks
+ where RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier
+ == identifier {
+ task.cancel()
+ }
+ }
+
+ func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> {
+ let tasks = await allTasks()
+ let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in
+ RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier
+ }
+ return Set(identifiers)
+ }
+
+ func handleBackgroundEvents(
+ identifier: String, completionHandler: @escaping @Sendable () -> Void
+ ) async {
+ _ = backgroundSession()
+ await coordinator.handleBackgroundEvents(
+ identifier: identifier, completionHandler: completionHandler
+ )
+ }
+
+ private func allTasks() async -> [URLSessionTask] {
+ await withCheckedContinuation { continuation in
+ backgroundSession().getAllTasks { tasks in continuation.resume(returning: tasks) }
+ }
+ }
+
+ private func backgroundSession() -> URLSession {
+ if let session {
+ return session
+ }
+ removeOrphanedDownloadStagingFiles()
+ let configuration: URLSessionConfiguration
+ if usesForegroundSession {
+ configuration = .ephemeral
+ } else {
+ configuration = URLSessionConfiguration.background(withIdentifier: identifier)
+ configuration.sessionSendsLaunchEvents = true
+ configuration.isDiscretionary = false
+ }
+ let delegateQueue = OperationQueue()
+ delegateQueue.name = "org.radroots.background-transfer.\(identifier)"
+ delegateQueue.maxConcurrentOperationCount = 1
+ let delegate = RadrootsTransferSessionDelegate(
+ coordinator: coordinator, downloadStagingRoot: downloadStagingRoot, fileManager: fileManager
+ )
+ let session = URLSession(
+ configuration: configuration, delegate: delegate, delegateQueue: delegateQueue
+ )
+ self.session = session
+ sessionDelegate = delegate
+ sessionDelegateQueue = delegateQueue
+ return session
+ }
+
+ private func removeOrphanedDownloadStagingFiles() {
+ guard
+ let urls = try? fileManager.contentsOfDirectory(
+ at: downloadStagingRoot, includingPropertiesForKeys: nil
+ )
+ else { return }
+ for url in urls where url.pathExtension == "download" {
+ try? fileManager.removeItem(at: url)
+ }
+ }
+ }
+
+#endif
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundURLSessionDelegate.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundURLSessionDelegate.swift
@@ -0,0 +1,247 @@
+import Foundation
+
+#if os(iOS)
+ final class RadrootsTransferSessionDelegate: NSObject,
+ URLSessionDownloadDelegate, URLSessionDataDelegate,
+ URLSessionTaskDelegate, @unchecked Sendable {
+ private static let absoluteMaximumResponseBodyBytes = 65536
+ private let coordinator: RadrootsTransferCoordinator
+ private let downloadStagingRoot: URL
+ private let fileManager: FileManager
+ private let lock = NSLock()
+ private var stagedDownloadResultsByTaskIdentifier: [Int: RadrootsStagedBackgroundDownloadResult]
+ private var responseBodyLimitsByTaskIdentifier: [Int: Int]
+ private var responseBodiesByTaskIdentifier: [Int: Data]
+ private var exceededResponseBodyTaskIdentifiers: Set<Int>
+
+ init(
+ coordinator: RadrootsTransferCoordinator, downloadStagingRoot: URL,
+ fileManager: FileManager
+ ) {
+ self.coordinator = coordinator
+ self.downloadStagingRoot = downloadStagingRoot
+ self.fileManager = fileManager
+ stagedDownloadResultsByTaskIdentifier = [:]
+ responseBodyLimitsByTaskIdentifier = [:]
+ responseBodiesByTaskIdentifier = [:]
+ exceededResponseBodyTaskIdentifiers = []
+ }
+
+ func urlSession(
+ _: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL
+ ) {
+ guard let identifier = transferIdentifier(from: downloadTask) else { return }
+ let result: RadrootsStagedBackgroundDownloadResult
+ do {
+ guard
+ let descriptor = RadrootsBackgroundURLTaskDescriptor(
+ taskDescription: downloadTask.taskDescription
+ ),
+ let fileSize = try location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
+ fileSize >= 0,
+ UInt64(fileSize) <= descriptor.maximumTransferBytes
+ else {
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
+ try fileManager.createDirectory(at: downloadStagingRoot, withIntermediateDirectories: true)
+ let destination = downloadStagingRoot.appendingPathComponent(
+ "\(identifier.rawValue)-\(downloadTask.taskIdentifier).download"
+ ).standardizedFileURL
+ if fileManager.fileExists(atPath: destination.path) {
+ try fileManager.removeItem(at: destination)
+ }
+ try fileManager.moveItem(at: location, to: destination)
+ try fileManager.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
+ ofItemAtPath: destination.path
+ )
+ result = .file(destination)
+ } catch {
+ result = .failure
+ }
+ recordDownloadResult(result, taskIdentifier: downloadTask.taskIdentifier)
+ }
+
+ func urlSession(
+ _: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64,
+ totalBytesWritten: Int64,
+ totalBytesExpectedToWrite: Int64
+ ) {
+ guard let identifier = transferIdentifier(from: downloadTask) else { return }
+ if exceedsTransferLimit(
+ task: downloadTask,
+ bytesTransferred: totalBytesWritten,
+ totalBytesExpected: totalBytesExpectedToWrite
+ ) {
+ downloadTask.cancel()
+ return
+ }
+ Task {
+ await coordinator.updateProgress(
+ identifier: identifier, bytesTransferred: totalBytesWritten,
+ totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToWrite),
+ executionID: RadrootsBackgroundURLTaskDescriptor(taskDescription: downloadTask.taskDescription)?
+ .executionID
+ )
+ }
+ }
+
+ func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
+ let shouldCancel = appendResponseBody(data, task: dataTask)
+ if shouldCancel {
+ dataTask.cancel()
+ }
+ }
+
+ func urlSession(
+ _: URLSession, task: URLSessionTask, didSendBodyData _: Int64, totalBytesSent: Int64,
+ totalBytesExpectedToSend: Int64
+ ) {
+ guard let identifier = transferIdentifier(from: task) else { return }
+ if exceedsTransferLimit(
+ task: task, bytesTransferred: totalBytesSent, totalBytesExpected: totalBytesExpectedToSend
+ ) {
+ task.cancel()
+ return
+ }
+ Task {
+ await coordinator.updateProgress(
+ identifier: identifier, bytesTransferred: totalBytesSent,
+ totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend)
+ )
+ }
+ }
+
+ func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+ let bytesTransferred = max(max(task.countOfBytesReceived, task.countOfBytesSent), 0)
+ let expected = Self.expectedByteCount(
+ max(task.countOfBytesExpectedToReceive, task.countOfBytesExpectedToSend)
+ )
+ let stagedDownloadResult = takeDownloadResult(taskIdentifier: task.taskIdentifier)
+ let httpResult = takeHTTPResult(for: task)
+ guard let identifier = transferIdentifier(from: task) else {
+ if case let .file(url) = stagedDownloadResult {
+ try? fileManager.removeItem(at: url)
+ }
+ return
+ }
+ Task {
+ await coordinator.complete(
+ identifier: identifier,
+ completion: RadrootsTransferCompletion(platformError: error,
+ stagedDownloadResult: stagedDownloadResult,
+ httpResult: httpResult,
+ bytesTransferred: bytesTransferred,
+ totalBytesExpected: expected),
+ executionID: RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.executionID
+ )
+ await coordinator.releaseUploadLease(
+ executionID: RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.executionID
+ )
+ }
+ }
+
+ func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
+ Task {
+ await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier)
+ }
+ }
+
+ func urlSession(
+ _: URLSession,
+ task _: URLSessionTask,
+ willPerformHTTPRedirection _: HTTPURLResponse,
+ newRequest _: URLRequest,
+ completionHandler: @escaping (URLRequest?) -> Void
+ ) {
+ completionHandler(nil)
+ }
+
+ private func recordDownloadResult(
+ _ result: RadrootsStagedBackgroundDownloadResult, taskIdentifier: Int
+ ) {
+ lock.lock()
+ defer { lock.unlock() }
+ stagedDownloadResultsByTaskIdentifier[taskIdentifier] = result
+ }
+
+ private func takeDownloadResult(taskIdentifier: Int) -> RadrootsStagedBackgroundDownloadResult? {
+ lock.lock()
+ defer { lock.unlock() }
+ return stagedDownloadResultsByTaskIdentifier.removeValue(forKey: taskIdentifier)
+ }
+
+ func registerResponseBodyLimit(_ limit: Int, taskIdentifier: Int) {
+ lock.lock()
+ defer { lock.unlock() }
+ responseBodyLimitsByTaskIdentifier[taskIdentifier] = min(
+ max(limit, 0), Self.absoluteMaximumResponseBodyBytes
+ )
+ }
+
+ private func appendResponseBody(_ data: Data, task: URLSessionDataTask) -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ let taskIdentifier = task.taskIdentifier
+ guard !exceededResponseBodyTaskIdentifiers.contains(taskIdentifier) else { return false }
+ let limit =
+ responseBodyLimitsByTaskIdentifier[taskIdentifier]
+ ?? RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?
+ .maximumResponseBodyBytes
+ ?? Self.absoluteMaximumResponseBodyBytes
+ guard limit > 0 else { return false }
+ let currentCount = responseBodiesByTaskIdentifier[taskIdentifier]?.count ?? 0
+ guard data.count <= limit - currentCount else {
+ responseBodiesByTaskIdentifier.removeValue(forKey: taskIdentifier)
+ exceededResponseBodyTaskIdentifiers.insert(taskIdentifier)
+ return true
+ }
+ responseBodiesByTaskIdentifier[taskIdentifier, default: Data()].append(data)
+ return false
+ }
+
+ private func takeHTTPResult(for task: URLSessionTask) -> RadrootsBackgroundHTTPResult {
+ lock.lock()
+ let body = responseBodiesByTaskIdentifier.removeValue(forKey: task.taskIdentifier)
+ responseBodyLimitsByTaskIdentifier.removeValue(forKey: task.taskIdentifier)
+ let exceeded = exceededResponseBodyTaskIdentifiers.remove(task.taskIdentifier) != nil
+ lock.unlock()
+
+ guard let response = task.response as? HTTPURLResponse else {
+ return RadrootsBackgroundHTTPResult(
+ statusCode: nil, mediaType: nil, body: body, bodyExceeded: exceeded
+ )
+ }
+ let rawMediaType = response.value(forHTTPHeaderField: "Content-Type")
+ let mediaType = rawMediaType.flatMap {
+ try? RadrootsBackgroundTransferValidation.normalizedMediaType($0)
+ }
+ let contentEncoding = response.value(forHTTPHeaderField: "Content-Encoding")?
+ .trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return RadrootsBackgroundHTTPResult(
+ statusCode: response.statusCode, mediaType: mediaType, body: body,
+ contentEncoding: contentEncoding, bodyExceeded: exceeded,
+ mediaTypeWasMalformed: rawMediaType != nil && mediaType == nil
+ )
+ }
+
+ private func transferIdentifier(from task: URLSessionTask)
+ -> RadrootsBackgroundTransferIdentifier? {
+ RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier
+ }
+
+ private func exceedsTransferLimit(
+ task: URLSessionTask, bytesTransferred: Int64, totalBytesExpected: Int64
+ ) -> Bool {
+ guard
+ let descriptor = RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)
+ else { return true }
+ return bytesTransferred > 0 && UInt64(bytesTransferred) > descriptor.maximumTransferBytes
+ || totalBytesExpected > 0 && UInt64(totalBytesExpected) > descriptor.maximumTransferBytes
+ }
+
+ private static func expectedByteCount(_ value: Int64) -> Int64? {
+ value >= 0 ? value : nil
+ }
+ }
+#endif
diff --git a/Sources/RadrootsKit/RadrootsAtomicFile.swift b/Sources/RadrootsKit/RadrootsAtomicFile.swift
@@ -9,6 +9,53 @@ enum RadrootsAtomicFile {
enum Phase: CaseIterable { case afterWriteChunk, afterWrite, beforeFileSync, beforeInstall, beforeDirectorySync }
static let maximumBytes = 512 * 1024 * 1024
+ /// A short, synchronous cross-owner transaction. Contention fails closed
+ /// instead of blocking a cooperative executor or awaiting under a lock.
+ static func withExclusiveLock<T>(at url: URL, _ body: () throws -> T) throws -> T {
+ guard let descriptor = try acquireExclusiveLock(at: url) else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ defer { Darwin.close(descriptor) }
+ return try body()
+ }
+
+ /// The caller owns and closes the returned descriptor. Nil denotes only
+ /// active contention; invalid paths and I/O failures remain errors.
+ static func acquireExclusiveLock(at url: URL) throws -> Int32? {
+ let parts = url.path.split(separator: "/").map(String.init)
+ guard url.isFileURL, !url.path.utf8.contains(0), let leaf = parts.last,
+ parts.allSatisfy({ $0 != "." && $0 != ".." })
+ else { throw RadrootsAppleFileError.invalidRequest }
+ let directory = try Directory.open(Array(parts.dropLast()), create: true)
+ defer { Darwin.close(directory.descriptor) }
+ let descriptor = leaf.withCString {
+ Darwin.openat(
+ directory.descriptor,
+ $0,
+ O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK | O_EXLOCK,
+ 0o600
+ )
+ }
+ guard descriptor >= 0 else {
+ if errno == EWOULDBLOCK {
+ return nil
+ }
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ var value = stat()
+ guard Darwin.fstat(descriptor, &value) == 0, value.st_mode & S_IFMT == S_IFREG
+ else {
+ Darwin.close(descriptor)
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ do {
+ try directory.validate()
+ } catch {
+ Darwin.close(descriptor); throw error
+ }
+ return descriptor
+ }
+
static func install(_ data: Data, at url: URL, mode: Mode = .replace, readOnly: Bool = false) throws {
try install(data, at: url, mode: mode, readOnly: readOnly, fault: nil)
}
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift
@@ -8,84 +8,80 @@ public enum RadrootsBackgroundTransferError: Error, Equatable, Sendable {
}
extension RadrootsBackgroundTransferError: LocalizedError {
- public var errorDescription: String? {
- switch self {
+ public var errorDescription: String? {
+ switch self {
case .invalidRequest: "The background transfer request is invalid."
case .unavailable: "Background transfer is unavailable."
case .transferFailure: "The background transfer could not be completed."
case .persistenceFailure: "The background transfer state could not be saved."
+ }
}
- }
}
public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable,
- Codable
-{
- public let rawValue: String
+ Codable {
+ public let rawValue: String
- public init(_ value: String) throws {
- rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value)
- }
+ public init(_ value: String) throws {
+ rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value)
+ }
- public static func generated() -> Self {
- Self(validatedRawValue: UUID().uuidString.lowercased())
- }
+ public static func generated() -> Self {
+ Self(validatedRawValue: UUID().uuidString.lowercased())
+ }
- public static func < (lhs: Self, rhs: Self) -> Bool {
- lhs.rawValue < rhs.rawValue
- }
+ public static func < (lhs: Self, rhs: Self) -> Bool {
+ lhs.rawValue < rhs.rawValue
+ }
- private init(validatedRawValue: String) {
- rawValue = validatedRawValue
- }
+ private init(validatedRawValue: String) {
+ rawValue = validatedRawValue
+ }
- private enum CodingKeys: String, CodingKey { case rawValue }
+ private enum CodingKeys: String, CodingKey { case rawValue }
- public init(from decoder: any Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- try self.init(values.decode(String.self, forKey: .rawValue))
- }
+ public init(from decoder: any Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ try self.init(values.decode(String.self, forKey: .rawValue))
+ }
- public func encode(to encoder: any Encoder) throws {
- var values = encoder.container(keyedBy: CodingKeys.self)
- try values.encode(rawValue, forKey: .rawValue)
- }
+ public func encode(to encoder: any Encoder) throws {
+ var values = encoder.container(keyedBy: CodingKeys.self)
+ try values.encode(rawValue, forKey: .rawValue)
+ }
}
public enum RadrootsBackgroundTransferMethod: String, Sendable, Equatable, Hashable, Codable,
- CaseIterable
-{
- case get = "GET"
- case post = "POST"
- case put = "PUT"
+ CaseIterable {
+ case get = "GET"
+ case post = "POST"
+ case put = "PUT"
}
public enum RadrootsBackgroundTransferLocalFile: Sendable, Equatable, Hashable, Codable {
- case file(RadrootsFileReference)
- case stagedBlob(RadrootsStagedBlobReference)
+ case file(RadrootsFileReference)
+ case stagedBlob(RadrootsStagedBlobReference)
}
public enum RadrootsBackgroundTransferOperation: Sendable, Equatable, Hashable, Codable {
- case download(destination: RadrootsBackgroundTransferLocalFile)
- case upload(source: RadrootsBackgroundTransferLocalFile)
+ case download(destination: RadrootsBackgroundTransferLocalFile)
+ case upload(source: RadrootsBackgroundTransferLocalFile)
}
public enum RadrootsBackgroundTransferState: String, Sendable, Equatable, Hashable, Codable,
- CaseIterable
-{
- case queued
- case running
- case awaitingVerification
- case completed
- case failed
- case cancelled
- case expired
- case interrupted
+ CaseIterable {
+ case queued
+ case running
+ case awaitingVerification
+ case completed
+ case failed
+ case cancelled
+ case expired
+ case interrupted
}
public enum RadrootsBackgroundTransferFailure: String, Sendable, Equatable, Hashable, Codable,
- CaseIterable
-{
+ CaseIterable {
case enqueueFailed = "background_transfer_enqueue_failed"
case expired = "background_transfer_expired"
case interrupted = "background_transfer_interrupted"
@@ -103,946 +99,107 @@ public enum RadrootsBackgroundTransferFailure: String, Sendable, Equatable, Hash
}
public enum RadrootsBackgroundTransferNetworkPolicy: String, Sendable, Equatable, Hashable, Codable {
- case publicHTTPS
- case simulatorLoopbackHTTP
-}
-
-public struct RadrootsBackgroundTransferResponsePolicy: Sendable, Equatable, Hashable, Codable {
- public let maximumBodyBytes: Int
- public let acceptedMediaTypes: [String]
-
- public init(maximumBodyBytes: Int = 0, acceptedMediaTypes: [String] = []) throws {
- guard (0...65536).contains(maximumBodyBytes), acceptedMediaTypes.count <= 8 else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- let normalized = try acceptedMediaTypes.map {
- try RadrootsBackgroundTransferValidation.normalizedMediaType($0)
- }
- guard Set(normalized).count == normalized.count, (maximumBodyBytes == 0) == normalized.isEmpty
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- self.maximumBodyBytes = maximumBodyBytes
- self.acceptedMediaTypes = normalized
- }
-
- public static let discard = Self(maximumBodyBytes: 0, acceptedMediaTypes: [], validated: ())
-
- public static func boundedJSON(maximumBodyBytes: Int = 16384) throws -> Self {
- try Self(maximumBodyBytes: maximumBodyBytes, acceptedMediaTypes: ["application/json"])
- }
-
- private init(maximumBodyBytes: Int, acceptedMediaTypes: [String], validated _: Void) {
- self.maximumBodyBytes = maximumBodyBytes
- self.acceptedMediaTypes = acceptedMediaTypes
- }
-
- private enum CodingKeys: String, CodingKey {
- case maximumBodyBytes
- case acceptedMediaTypes
- }
-
- public init(from decoder: any Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- try self.init(
- maximumBodyBytes: values.decode(Int.self, forKey: .maximumBodyBytes),
- acceptedMediaTypes: values.decode([String].self, forKey: .acceptedMediaTypes)
- )
- }
-
- public func encode(to encoder: any Encoder) throws {
- var values = encoder.container(keyedBy: CodingKeys.self)
- try values.encode(maximumBodyBytes, forKey: .maximumBodyBytes)
- try values.encode(acceptedMediaTypes, forKey: .acceptedMediaTypes)
- }
-}
-
-public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, Codable {
- public let statusCode: Int
- public let mediaType: String?
- public let contentEncoding: String?
- public let body: Data?
-
- public init(
- statusCode: Int, mediaType: String?, contentEncoding: String? = nil, body: Data?
- ) throws {
- guard (100...599).contains(statusCode), body?.count ?? 0 <= 65536 else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- self.statusCode = statusCode
- self.mediaType = try mediaType.map {
- try RadrootsBackgroundTransferValidation.normalizedMediaType($0)
- }
- let normalizedEncoding = contentEncoding?.trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
- guard normalizedEncoding == nil || normalizedEncoding == "identity" else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- self.contentEncoding = normalizedEncoding
- self.body = body
- }
-
- private enum CodingKeys: String, CodingKey {
- case statusCode
- case mediaType
- case contentEncoding
- case body
- }
-
- public init(from decoder: any Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- try self.init(
- statusCode: values.decode(Int.self, forKey: .statusCode),
- mediaType: values.decodeIfPresent(String.self, forKey: .mediaType),
- contentEncoding: values.decodeIfPresent(String.self, forKey: .contentEncoding),
- body: values.decodeIfPresent(Data.self, forKey: .body)
- )
- }
-
- public func encode(to encoder: any Encoder) throws {
- var values = encoder.container(keyedBy: CodingKeys.self)
- try values.encode(statusCode, forKey: .statusCode)
- try values.encodeIfPresent(mediaType, forKey: .mediaType)
- try values.encodeIfPresent(contentEncoding, forKey: .contentEncoding)
- try values.encodeIfPresent(body, forKey: .body)
- }
-}
-
-public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, Codable,
- CustomDebugStringConvertible
-{
- public static let defaultMaximumTransferBytes: UInt64 = 64 * 1024 * 1024
- public static let absoluteMaximumTransferBytes: UInt64 = 512 * 1024 * 1024
-
- public let identifier: RadrootsBackgroundTransferIdentifier
- public let remoteURL: URL
- public let method: RadrootsBackgroundTransferMethod
- public let operation: RadrootsBackgroundTransferOperation
- public let headers: [String: String]
- public let metadata: [String: String]
- public let networkPolicy: RadrootsBackgroundTransferNetworkPolicy
- public let responsePolicy: RadrootsBackgroundTransferResponsePolicy
- public let expectedSourceSHA256: String?
- public let maximumTransferBytes: UInt64
-
- public init(
- identifier: RadrootsBackgroundTransferIdentifier = .generated(), remoteURL: URL,
- method: RadrootsBackgroundTransferMethod,
- operation: RadrootsBackgroundTransferOperation, headers: [String: String] = [:],
- metadata: [String: String] = [:],
- networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS,
- responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard,
- expectedSourceSHA256: String? = nil,
- maximumTransferBytes: UInt64 = Self.defaultMaximumTransferBytes
- ) throws {
- try RadrootsBackgroundTransferValidation.validate(
- remoteURL: remoteURL, method: method, operation: operation, headers: headers,
- metadata: metadata, networkPolicy: networkPolicy,
- responsePolicy: responsePolicy, expectedSourceSHA256: expectedSourceSHA256,
- maximumTransferBytes: maximumTransferBytes
- )
- self.identifier = identifier
- self.remoteURL = remoteURL
- self.method = method
- self.operation = operation
- self.headers = headers
- self.metadata = metadata
- self.networkPolicy = networkPolicy
- self.responsePolicy = responsePolicy
- self.expectedSourceSHA256 = expectedSourceSHA256
- self.maximumTransferBytes = maximumTransferBytes
- }
-
- public var debugDescription: String {
- "RadrootsBackgroundTransferRequest(identifier: \(identifier.rawValue), method: \(method.rawValue), operation: \(operation.redactedLabel), headers: <redacted>, metadataKeys: \(metadata.keys.sorted()), maximumTransferBytes: \(maximumTransferBytes), responseBodyLimit: \(responsePolicy.maximumBodyBytes))"
- }
-
- func redactedForPersistence() throws -> Self {
- try Self(
- identifier: identifier, remoteURL: remoteURL, method: method, operation: operation,
- headers: [:], metadata: [:],
- networkPolicy: networkPolicy, responsePolicy: responsePolicy,
- expectedSourceSHA256: expectedSourceSHA256,
- maximumTransferBytes: maximumTransferBytes
- )
- }
-
- private enum CodingKeys: String, CodingKey {
- case identifier
- case remoteURL
- case method
- case operation
- case metadata
- case networkPolicy
- case responsePolicy
- case expectedSourceSHA256
- case maximumTransferBytes
- }
-
- public init(from decoder: any Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- try self.init(
- identifier: values.decode(RadrootsBackgroundTransferIdentifier.self, forKey: .identifier),
- remoteURL: values.decode(URL.self, forKey: .remoteURL),
- method: values.decode(RadrootsBackgroundTransferMethod.self, forKey: .method),
- operation: values.decode(RadrootsBackgroundTransferOperation.self, forKey: .operation),
- headers: [:],
- metadata: values.decode([String: String].self, forKey: .metadata),
- networkPolicy: values.decodeIfPresent(
- RadrootsBackgroundTransferNetworkPolicy.self, forKey: .networkPolicy) ?? .publicHTTPS,
- responsePolicy: values.decodeIfPresent(
- RadrootsBackgroundTransferResponsePolicy.self, forKey: .responsePolicy) ?? .discard,
- expectedSourceSHA256: values.decodeIfPresent(String.self, forKey: .expectedSourceSHA256),
- maximumTransferBytes: values.decodeIfPresent(UInt64.self, forKey: .maximumTransferBytes)
- ?? Self.defaultMaximumTransferBytes
- )
- }
-
- public func encode(to encoder: any Encoder) throws {
- var values = encoder.container(keyedBy: CodingKeys.self)
- try values.encode(identifier, forKey: .identifier)
- try values.encode(remoteURL, forKey: .remoteURL)
- try values.encode(method, forKey: .method)
- try values.encode(operation, forKey: .operation)
- try values.encode(metadata, forKey: .metadata)
- try values.encode(networkPolicy, forKey: .networkPolicy)
- try values.encode(responsePolicy, forKey: .responsePolicy)
- try values.encodeIfPresent(expectedSourceSHA256, forKey: .expectedSourceSHA256)
- try values.encode(maximumTransferBytes, forKey: .maximumTransferBytes)
- }
-}
-
-public struct RadrootsBackgroundDownloadedArtifact: Sendable, Equatable, Hashable, Codable,
- CustomDebugStringConvertible
-{
- public let file: RadrootsFileReference
- public let sha256: String
- public let byteSize: UInt64
- public let mediaType: String?
-
- public init(file: RadrootsFileReference, sha256: String, byteSize: UInt64, mediaType: String?)
- throws
- {
- guard sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, byteSize > 0,
- byteSize <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- try RadrootsBackgroundTransferValidation.validateLocalFile(.file(file))
- self.file = file
- self.sha256 = sha256
- self.byteSize = byteSize
- self.mediaType = try mediaType.map {
- try RadrootsBackgroundTransferValidation.normalizedMediaType($0)
- }
- }
-
- public var debugDescription: String {
- "RadrootsBackgroundDownloadedArtifact(sha256: \(sha256), byteSize: \(byteSize), mediaType: \(mediaType ?? "none"), file: <redacted>)"
- }
-}
-
-public enum RadrootsBackgroundTransferVerification: Sendable, Equatable {
- case accepted
- case rejected(failure: RadrootsBackgroundTransferFailure)
-}
-
-public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, Codable,
- CustomDebugStringConvertible
-{
- public let identifier: RadrootsBackgroundTransferIdentifier
- public let request: RadrootsBackgroundTransferRequest
-
- public init(request: RadrootsBackgroundTransferRequest) {
- identifier = request.identifier
- self.request = request
- }
-
- public var debugDescription: String {
- "RadrootsBackgroundTransferHandle(identifier: \(identifier.rawValue), request: \(request.debugDescription))"
- }
-
- private enum CodingKeys: String, CodingKey {
- case identifier
- case request
- }
-
- public init(from decoder: any Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- let identifier = try values.decode(
- RadrootsBackgroundTransferIdentifier.self, forKey: .identifier)
- let request = try values.decode(RadrootsBackgroundTransferRequest.self, forKey: .request)
- guard identifier == request.identifier else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- self.init(request: request)
- }
-
- public func encode(to encoder: any Encoder) throws {
- var values = encoder.container(keyedBy: CodingKeys.self)
- try values.encode(identifier, forKey: .identifier)
- try values.encode(request, forKey: .request)
- }
-}
-
-public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, Codable {
- public let bytesTransferred: Int64
- public let totalBytesExpected: Int64?
-
- public init(bytesTransferred: Int64, totalBytesExpected: Int64? = nil) throws {
- guard bytesTransferred >= 0 else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- if let totalBytesExpected {
- guard totalBytesExpected >= 0 else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- guard totalBytesExpected >= bytesTransferred else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- }
- self.bytesTransferred = bytesTransferred
- self.totalBytesExpected = totalBytesExpected
- }
-
- public static let zero = RadrootsBackgroundTransferProgress(
- validatedBytesTransferred: 0, totalBytesExpected: nil)
-
- private init(validatedBytesTransferred: Int64, totalBytesExpected: Int64?) {
- bytesTransferred = validatedBytesTransferred
- self.totalBytesExpected = totalBytesExpected
- }
-
- private enum CodingKeys: String, CodingKey {
- case bytesTransferred
- case totalBytesExpected
- }
-
- public init(from decoder: any Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- try self.init(
- bytesTransferred: values.decode(Int64.self, forKey: .bytesTransferred),
- totalBytesExpected: values.decodeIfPresent(Int64.self, forKey: .totalBytesExpected)
- )
- }
-
- public func encode(to encoder: any Encoder) throws {
- var values = encoder.container(keyedBy: CodingKeys.self)
- try values.encode(bytesTransferred, forKey: .bytesTransferred)
- try values.encodeIfPresent(totalBytesExpected, forKey: .totalBytesExpected)
- }
-}
-
-public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, Codable,
- CustomDebugStringConvertible
-{
- public let identifier: RadrootsBackgroundTransferIdentifier
- public let request: RadrootsBackgroundTransferRequest
- public let state: RadrootsBackgroundTransferState
- public let progress: RadrootsBackgroundTransferProgress
- public let failure: RadrootsBackgroundTransferFailure?
- public let response: RadrootsBackgroundTransferResponse?
- public let downloadedArtifact: RadrootsBackgroundDownloadedArtifact?
- public let possibleRemoteOrphan: Bool
- public let updatedAt: Date
-
- public init(
- request: RadrootsBackgroundTransferRequest, state: RadrootsBackgroundTransferState = .queued,
- progress: RadrootsBackgroundTransferProgress = .zero,
- failure: RadrootsBackgroundTransferFailure? = nil,
- response: RadrootsBackgroundTransferResponse? = nil,
- downloadedArtifact: RadrootsBackgroundDownloadedArtifact? = nil,
- possibleRemoteOrphan: Bool = false, updatedAt: Date = Date()
- ) throws {
- guard updatedAt.timeIntervalSinceReferenceDate.isFinite else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- guard response == nil || state == .awaitingVerification || state == .completed else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- if let response {
- try RadrootsBackgroundTransferValidation.validate(
- response: response, policy: request.responsePolicy)
- }
- if possibleRemoteOrphan {
- guard case .upload = request.operation,
- state == .failed || state == .interrupted || state == .cancelled || state == .expired
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- }
- if let downloadedArtifact {
- guard case .download(let destination) = request.operation,
- destination == .file(downloadedArtifact.file),
- state == .awaitingVerification || state == .completed
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- }
- identifier = request.identifier
- self.request = request
- self.state = state
- self.progress = progress
- self.failure = failure
- self.response = response
- self.downloadedArtifact = downloadedArtifact
- self.possibleRemoteOrphan = possibleRemoteOrphan
- self.updatedAt = updatedAt
- }
-
- public var debugDescription: String {
- let statusCode = response?.statusCode.description ?? "none"
- return
- "RadrootsBackgroundTransferSnapshot(identifier: \(identifier.rawValue), state: \(state.rawValue), bytesTransferred: \(progress.bytesTransferred), statusCode: \(statusCode), downloadedArtifact: \(downloadedArtifact == nil ? "none" : "present"), possibleRemoteOrphan: \(possibleRemoteOrphan))"
- }
-
- func redactedForPersistence() throws -> Self {
- try Self(
- request: request.redactedForPersistence(), state: state, progress: progress,
- failure: failure, response: response,
- downloadedArtifact: downloadedArtifact, possibleRemoteOrphan: possibleRemoteOrphan,
- updatedAt: updatedAt
- )
- }
-
- private enum CodingKeys: String, CodingKey {
- case request
- case state
- case progress
- case failure
- case response
- case downloadedArtifact
- case possibleRemoteOrphan
- case updatedAt
- }
-
- public init(from decoder: any Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- try self.init(
- request: values.decode(RadrootsBackgroundTransferRequest.self, forKey: .request),
- state: values.decode(RadrootsBackgroundTransferState.self, forKey: .state),
- progress: values.decode(RadrootsBackgroundTransferProgress.self, forKey: .progress),
- failure: values.decodeIfPresent(RadrootsBackgroundTransferFailure.self, forKey: .failure),
- response: values.decodeIfPresent(RadrootsBackgroundTransferResponse.self, forKey: .response),
- downloadedArtifact: values.decodeIfPresent(
- RadrootsBackgroundDownloadedArtifact.self, forKey: .downloadedArtifact),
- possibleRemoteOrphan: values.decodeIfPresent(Bool.self, forKey: .possibleRemoteOrphan)
- ?? false,
- updatedAt: values.decode(Date.self, forKey: .updatedAt)
- )
- }
-
- public func encode(to encoder: any Encoder) throws {
- var values = encoder.container(keyedBy: CodingKeys.self)
- try values.encode(request, forKey: .request)
- try values.encode(state, forKey: .state)
- try values.encode(progress, forKey: .progress)
- try values.encodeIfPresent(failure, forKey: .failure)
- try values.encodeIfPresent(response, forKey: .response)
- try values.encodeIfPresent(downloadedArtifact, forKey: .downloadedArtifact)
- try values.encode(possibleRemoteOrphan, forKey: .possibleRemoteOrphan)
- try values.encode(updatedAt, forKey: .updatedAt)
- }
+ case publicHTTPS
+ case simulatorLoopbackHTTP
}
public protocol RadrootsBackgroundTransferStore: Sendable {
- func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot]
- func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws
- func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
- func removeAllSnapshots() async throws
+ /// Serializes admission across suspension and independent owners. This is a
+ /// nonblocking reservation, separate from short persistence transactions.
+ func withAdmission(
+ for identifier: RadrootsBackgroundTransferIdentifier,
+ operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle
+ ) async throws -> RadrootsBackgroundTransferHandle
+ func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool
+ /// Atomically installs a redacted snapshot only if the exact expected value
+ /// still owns the identifier. Nil reserves a previously absent identifier.
+ func compareExchangeSnapshot(
+ expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot
+ ) async throws -> Bool
+ func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot]
+ func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws
+ func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
+ func removeAllSnapshots() async throws
}
public protocol RadrootsBackgroundTransfer: Sendable {
- func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- func retry(_ request: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws
- func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws
- func settle(
- _ identifier: RadrootsBackgroundTransferIdentifier,
- verification: RadrootsBackgroundTransferVerification) async throws
- func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
- -> RadrootsBackgroundTransferSnapshot?
- func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot]
- func handleEventsForBackgroundURLSession(
- identifier: String, completionHandler: @escaping @Sendable () -> Void) async
+ func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws
+ -> RadrootsBackgroundTransferHandle
+ func retry(_ request: RadrootsBackgroundTransferRequest) async throws
+ -> RadrootsBackgroundTransferHandle
+ func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws
+ func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws
+ func settle(
+ _ identifier: RadrootsBackgroundTransferIdentifier,
+ verification: RadrootsBackgroundTransferVerification
+ ) async throws
+ func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
+ -> RadrootsBackgroundTransferSnapshot?
+ func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot]
+ func handleEventsForBackgroundURLSession(
+ identifier: String, completionHandler: @escaping @Sendable () -> Void
+ ) async
}
public protocol RadrootsBackgroundTransferFileResolver: Sendable {
- func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL
- func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data
-}
-
-public struct RadrootsAppleBackgroundTransferFileResolver: RadrootsBackgroundTransferFileResolver,
- Sendable
-{
- private let roots: RadrootsAppleFileRoots
-
- public init(roots: RadrootsAppleFileRoots) {
- self.roots = roots
- }
-
- public func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL {
- let candidate: URL
- let root: URL
- switch file {
- case .file(let reference):
- candidate = try roots.resolvedURL(for: reference)
- root = roots.root(for: reference.scope)
- case .stagedBlob(let blob):
- candidate = try roots.stagedBlobURL(for: blob)
- root = roots.stagedBlobsRoot
- }
- let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL
- let resolvedCandidate = candidate.resolvingSymlinksInPath().standardizedFileURL
- guard resolvedCandidate.path.hasPrefix(resolvedRoot.path + "/") else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- return candidate
- }
-
- public func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data {
- guard maximumBytes >= 0 else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- let root: URL
- let relativePath: String
- let expectedBytes: Int?
- switch file {
- case .file(let reference):
- root = roots.root(for: reference.scope)
- relativePath = reference.relativePath
- expectedBytes = nil
- case .stagedBlob(let blob):
- root = roots.stagedBlobsRoot
- relativePath = blob.blobID
- expectedBytes = blob.sizeBytes
- }
- do {
- let data = try RadrootsGovernedFileReader.read(
- root: root,
- relativePath: relativePath,
- maximumBytes: maximumBytes
- )
- guard expectedBytes == nil || expectedBytes == data.count else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- return data
- } catch let error as RadrootsBackgroundTransferError {
- throw error
- } catch {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- }
-}
-
-public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore {
- private static let maximumPersistenceBytes = 1024 * 1024
-
- private struct Envelope: Codable {
- let schemaVersion: Int
- let snapshots: [RadrootsBackgroundTransferSnapshot]
-
- init(snapshots: [RadrootsBackgroundTransferSnapshot]) {
- schemaVersion = 1
- self.snapshots = snapshots
- }
- }
-
- private let roots: RadrootsAppleFileRoots
- private let fileManager: FileManager
- private let encoder: JSONEncoder
- private let decoder: JSONDecoder
- private let protectedData: RadrootsProtectedDataProvider
-
- public init(
- roots: RadrootsAppleFileRoots, fileManager: FileManager = .default,
- protectedData: RadrootsProtectedDataProvider = .available
- ) {
- self.roots = roots
- self.fileManager = fileManager
- encoder = JSONEncoder()
- decoder = JSONDecoder()
- self.protectedData = protectedData
- encoder.outputFormatting = [.sortedKeys]
- }
-
- public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
- try requireProtectedData()
- do {
- let url = try storeURL()
- let legacyURL = try legacyStoreURL()
- let isLegacy: Bool
- if fileManager.fileExists(atPath: url.path) {
- isLegacy = false
- } else if fileManager.fileExists(atPath: legacyURL.path) {
- isLegacy = true
- } else {
- return []
- }
- let data = try RadrootsGovernedFileReader.read(
- root: roots.root(for: isLegacy ? .cache : .data),
- relativePath: "background_transfers/transfers.json",
- maximumBytes: Self.maximumPersistenceBytes
- )
- let decoded: [RadrootsBackgroundTransferSnapshot]
- let usedLegacyEncoding: Bool
- if let envelope = try? decoder.decode(Envelope.self, from: data) {
- guard envelope.schemaVersion == 1 else {
- throw RadrootsBackgroundTransferError.persistenceFailure
- }
- decoded = envelope.snapshots
- usedLegacyEncoding = false
- } else {
- decoded = try decoder.decode([RadrootsBackgroundTransferSnapshot].self, from: data)
- usedLegacyEncoding = true
- }
- let snapshots = try decoded.map { try $0.redactedForPersistence() }.sorted { left, right in
- left.identifier < right.identifier
- }
- if isLegacy || usedLegacyEncoding {
- try write(snapshots)
- }
- if fileManager.fileExists(atPath: legacyURL.path) {
- try fileManager.removeItem(at: legacyURL)
- }
- return snapshots
- } catch {
- throw RadrootsBackgroundTransferError.persistenceFailure
- }
- }
-
- public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws {
- try requireProtectedData()
- do {
- var snapshots = try await loadSnapshots()
- snapshots.removeAll { $0.identifier == snapshot.identifier }
- try snapshots.append(snapshot.redactedForPersistence())
- try write(snapshots.sorted { left, right in left.identifier < right.identifier })
- } catch let error as RadrootsBackgroundTransferError { throw error } catch {
- throw RadrootsBackgroundTransferError.persistenceFailure
- }
- }
-
- public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws {
- try requireProtectedData()
- do {
- var snapshots = try await loadSnapshots()
- snapshots.removeAll { $0.identifier == identifier }
- try write(snapshots)
- } catch let error as RadrootsBackgroundTransferError { throw error } catch {
- throw RadrootsBackgroundTransferError.persistenceFailure
- }
- }
-
- public func removeAllSnapshots() async throws {
- try requireProtectedData()
- do {
- for url in try [storeURL(), legacyStoreURL()] where fileManager.fileExists(atPath: url.path) {
- try fileManager.removeItem(at: url)
- }
- } catch let error as RadrootsBackgroundTransferError { throw error } catch {
- throw RadrootsBackgroundTransferError.persistenceFailure
- }
- }
-
- private func write(_ snapshots: [RadrootsBackgroundTransferSnapshot]) throws {
- let url = try storeURL()
- try fileManager.createDirectory(
- at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
- let data = try encoder.encode(Envelope(snapshots: snapshots))
- guard data.count <= Self.maximumPersistenceBytes else {
- throw RadrootsBackgroundTransferError.persistenceFailure
- }
- try data.write(to: url, options: [.atomic])
- #if os(iOS)
- try fileManager.setAttributes(
- [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
- ofItemAtPath: url.path)
- #endif
- }
-
- private func storeURL() throws -> URL {
- try roots.resolvedURL(
- for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.json"))
- }
-
- private func legacyStoreURL() throws -> URL {
- try roots.resolvedURL(
- for: RadrootsFileReference(scope: .cache, relativePath: "background_transfers/transfers.json")
- )
- }
-
- private func requireProtectedData() throws {
- guard protectedData.currentState() == .available else {
- throw RadrootsBackgroundTransferError.persistenceFailure
- }
- }
+ func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL
+ func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data
+ func prepareUploadLease(
+ for request: RadrootsBackgroundTransferRequest, executionID: UUID,
+ existing: RadrootsStagedBlobReference?
+ ) throws -> RadrootsStagedBlobLease
+ func releaseUploadLease(executionID: UUID) throws
}
public struct RadrootsUnavailableBackgroundTransfer: RadrootsBackgroundTransfer, Sendable {
public init() {}
- public func enqueue(_: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- {
- throw RadrootsBackgroundTransferError.unavailable
- }
-
- public func retry(_: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- {
- throw RadrootsBackgroundTransferError.unavailable
- }
-
- public func cancel(_: RadrootsBackgroundTransferIdentifier) async throws {
+ public func enqueue(_: RadrootsBackgroundTransferRequest) async throws
+ -> RadrootsBackgroundTransferHandle {
throw RadrootsBackgroundTransferError.unavailable
- }
-
- public func expire(_: RadrootsBackgroundTransferIdentifier) async throws {
- throw RadrootsBackgroundTransferError.unavailable
- }
-
- public func settle(
- _: RadrootsBackgroundTransferIdentifier,
- verification _: RadrootsBackgroundTransferVerification
- ) async throws {
- throw RadrootsBackgroundTransferError.unavailable
- }
-
- public func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws
- -> RadrootsBackgroundTransferSnapshot?
- {
- throw RadrootsBackgroundTransferError.unavailable
- }
-
- public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
- throw RadrootsBackgroundTransferError.unavailable
- }
-
- public func handleEventsForBackgroundURLSession(
- identifier _: String, completionHandler: @escaping @Sendable () -> Void
- ) async {
- completionHandler()
- }
-}
-
-public enum RadrootsBackgroundTransferValidation {
- public static func normalizedIdentifier(_ value: String) throws -> String {
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- guard !trimmed.isEmpty else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- guard trimmed.count <= 128 else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- guard
- trimmed.range(of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", options: .regularExpression)
- != nil
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- guard !trimmed.contains("..") else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- return trimmed
- }
-
- public static func validate(
- remoteURL: URL, method: RadrootsBackgroundTransferMethod,
- operation: RadrootsBackgroundTransferOperation, headers: [String: String],
- metadata: [String: String], networkPolicy: RadrootsBackgroundTransferNetworkPolicy,
- responsePolicy: RadrootsBackgroundTransferResponsePolicy, expectedSourceSHA256: String?,
- maximumTransferBytes: UInt64
- ) throws {
- try validate(remoteURL: remoteURL, networkPolicy: networkPolicy)
- try validate(method: method, operation: operation)
- try validate(headers: headers)
- try validate(metadata: metadata)
- guard maximumTransferBytes > 0,
- maximumTransferBytes <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- if case .download = operation, responsePolicy != .discard {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- if let expectedSourceSHA256 {
- guard case .upload = operation,
- expectedSourceSHA256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- }
- if case .upload(.stagedBlob(let blob)) = operation,
- UInt64(blob.sizeBytes) > maximumTransferBytes
- {
- throw RadrootsBackgroundTransferError.invalidRequest
}
- }
- private static func validate(
- remoteURL: URL, networkPolicy: RadrootsBackgroundTransferNetworkPolicy
- ) throws {
- guard let components = URLComponents(url: remoteURL, resolvingAgainstBaseURL: false),
- components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
- components.user == nil,
- components.password == nil, components.query == nil, components.fragment == nil
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- let scheme = components.scheme?.lowercased()
- switch networkPolicy {
- case .publicHTTPS:
- guard scheme == "https" else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- case .simulatorLoopbackHTTP:
- #if os(iOS) && !targetEnvironment(simulator)
- throw RadrootsBackgroundTransferError.invalidRequest
- #else
- guard scheme == "http", let host = components.host?.lowercased(),
- host == "localhost" || host == "127.0.0.1" || host == "::1"
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- #endif
- }
- }
-
- private static func validate(
- method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation
- ) throws {
- switch operation {
- case .download(let destination):
- guard method == .get else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- guard case .file = destination else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- try validateLocalFile(destination)
- case .upload(let source):
- guard method == .post || method == .put else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- try validateLocalFile(source)
+ public func retry(_: RadrootsBackgroundTransferRequest) async throws
+ -> RadrootsBackgroundTransferHandle {
+ throw RadrootsBackgroundTransferError.unavailable
}
- }
- static func validateLocalFile(_ localFile: RadrootsBackgroundTransferLocalFile) throws {
- switch localFile {
- case .file(let reference):
- let path = reference.relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !path.isEmpty, !NSString(string: path).isAbsolutePath,
- !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." }
- )
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- case .stagedBlob(let blob):
- guard
- (try? RadrootsStagedBlobReference(
- blobID: blob.blobID, sizeBytes: blob.sizeBytes, mediaType: blob.mediaType,
- filenameHint: blob.filenameHint
- )) != nil
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
+ public func cancel(_: RadrootsBackgroundTransferIdentifier) async throws {
+ throw RadrootsBackgroundTransferError.unavailable
}
- }
- private static func validate(headers: [String: String]) throws {
- guard headers.count <= 32 else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- for (key, value) in headers {
- try validateSafeText(key, field: "background transfer header name", maximumLength: 80)
- guard key.range(of: "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$", options: .regularExpression) != nil,
- !["connection", "content-length", "host", "transfer-encoding"].contains(key.lowercased())
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- try validateSafeText(value, field: "background transfer header value", maximumLength: 8192)
+ public func expire(_: RadrootsBackgroundTransferIdentifier) async throws {
+ throw RadrootsBackgroundTransferError.unavailable
}
- }
- private static func validate(metadata: [String: String]) throws {
- guard metadata.count <= 32 else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- for (key, value) in metadata {
- try validateSafeText(key, field: "background transfer metadata key", maximumLength: 80)
- try validateSafeText(value, field: "background transfer metadata value", maximumLength: 500)
- let unsafeKey = key.lowercased()
- guard
- !["auth", "token", "secret", "credential", "cookie", "header", "path", "url"].contains(
- where: { unsafeKey.contains($0) })
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
+ public func settle(
+ _: RadrootsBackgroundTransferIdentifier,
+ verification _: RadrootsBackgroundTransferVerification
+ ) async throws {
+ throw RadrootsBackgroundTransferError.unavailable
}
- }
- static func normalizedMediaType(_ value: String) throws -> String {
- let normalized =
- value.split(separator: ";", maxSplits: 1).first?.trimmingCharacters(
- in: .whitespacesAndNewlines
- ).lowercased() ?? ""
- guard
- normalized.range(of: "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", options: .regularExpression)
- != nil,
- normalized.utf8.count <= 127
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
+ public func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws
+ -> RadrootsBackgroundTransferSnapshot? {
+ throw RadrootsBackgroundTransferError.unavailable
}
- return normalized
- }
- static func validate(
- response: RadrootsBackgroundTransferResponse, policy: RadrootsBackgroundTransferResponsePolicy
- ) throws {
- guard (200...299).contains(response.statusCode) else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- if policy.maximumBodyBytes == 0 {
- guard response.body == nil, response.mediaType == nil else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- return
- }
- guard let body = response.body, body.count <= policy.maximumBodyBytes,
- let mediaType = response.mediaType,
- policy.acceptedMediaTypes.contains(mediaType)
- else {
- throw RadrootsBackgroundTransferError.invalidRequest
+ public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ throw RadrootsBackgroundTransferError.unavailable
}
- }
- private static func validateSafeText(_ value: String, field: String, maximumLength: Int) throws {
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else {
- throw RadrootsBackgroundTransferError.invalidRequest
+ public func handleEventsForBackgroundURLSession(
+ identifier _: String, completionHandler: @escaping @Sendable () -> Void
+ ) async {
+ completionHandler()
}
- guard trimmed.count <= maximumLength else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- guard doesNotContainControlCharacters(trimmed) else {
- throw RadrootsBackgroundTransferError.invalidRequest
- }
- }
-
- private static func doesNotContainControlCharacters(_ value: String) -> Bool {
- value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) }
- }
}
extension RadrootsBackgroundTransferOperation {
- fileprivate var redactedLabel: String {
- switch self {
- case .download: "download"
- case .upload: "upload"
+ var redactedLabel: String {
+ switch self {
+ case .download: "download"
+ case .upload: "upload"
+ }
}
- }
}
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferRequest.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferRequest.swift
@@ -0,0 +1,108 @@
+import Foundation
+
+public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, Codable,
+ CustomDebugStringConvertible {
+ public static let defaultMaximumTransferBytes: UInt64 = 64 * 1024 * 1024
+ public static let absoluteMaximumTransferBytes: UInt64 = 512 * 1024 * 1024
+
+ public let identifier: RadrootsBackgroundTransferIdentifier
+ public let remoteURL: URL
+ public let method: RadrootsBackgroundTransferMethod
+ public let operation: RadrootsBackgroundTransferOperation
+ public let headers: [String: String]
+ public let metadata: [String: String]
+ public let networkPolicy: RadrootsBackgroundTransferNetworkPolicy
+ public let responsePolicy: RadrootsBackgroundTransferResponsePolicy
+ public let expectedSourceSHA256: String?
+ public let maximumTransferBytes: UInt64
+
+ public init(
+ identifier: RadrootsBackgroundTransferIdentifier = .generated(), remoteURL: URL,
+ method: RadrootsBackgroundTransferMethod,
+ operation: RadrootsBackgroundTransferOperation, headers: [String: String] = [:],
+ metadata: [String: String] = [:],
+ networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS,
+ responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard,
+ expectedSourceSHA256: String? = nil,
+ maximumTransferBytes: UInt64 = Self.defaultMaximumTransferBytes
+ ) throws {
+ try RadrootsBackgroundTransferValidation.validate(
+ remoteURL: remoteURL, method: method, operation: operation, headers: headers,
+ metadata: metadata, networkPolicy: networkPolicy,
+ responsePolicy: responsePolicy, expectedSourceSHA256: expectedSourceSHA256,
+ maximumTransferBytes: maximumTransferBytes
+ )
+ self.identifier = identifier
+ self.remoteURL = remoteURL
+ self.method = method
+ self.operation = operation
+ self.headers = headers
+ self.metadata = metadata
+ self.networkPolicy = networkPolicy
+ self.responsePolicy = responsePolicy
+ self.expectedSourceSHA256 = expectedSourceSHA256
+ self.maximumTransferBytes = maximumTransferBytes
+ }
+
+ public var debugDescription: String {
+ "RadrootsBackgroundTransferRequest(identifier: \(identifier.rawValue), method: \(method.rawValue), "
+ + "operation: \(operation.redactedLabel), headers: <redacted>, metadataKeys: \(metadata.keys.sorted()), "
+ + "maximumTransferBytes: \(maximumTransferBytes), responseBodyLimit: \(responsePolicy.maximumBodyBytes))"
+ }
+
+ func redactedForPersistence() throws -> Self {
+ try Self(
+ identifier: identifier, remoteURL: remoteURL, method: method, operation: operation,
+ headers: [:], metadata: [:],
+ networkPolicy: networkPolicy, responsePolicy: responsePolicy,
+ expectedSourceSHA256: expectedSourceSHA256,
+ maximumTransferBytes: maximumTransferBytes
+ )
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case identifier
+ case remoteURL
+ case method
+ case operation
+ case metadata
+ case networkPolicy
+ case responsePolicy
+ case expectedSourceSHA256
+ case maximumTransferBytes
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ try self.init(
+ identifier: values.decode(RadrootsBackgroundTransferIdentifier.self, forKey: .identifier),
+ remoteURL: values.decode(URL.self, forKey: .remoteURL),
+ method: values.decode(RadrootsBackgroundTransferMethod.self, forKey: .method),
+ operation: values.decode(RadrootsBackgroundTransferOperation.self, forKey: .operation),
+ headers: [:],
+ metadata: values.decode([String: String].self, forKey: .metadata),
+ networkPolicy: values.decodeIfPresent(
+ RadrootsBackgroundTransferNetworkPolicy.self, forKey: .networkPolicy
+ ) ?? .publicHTTPS,
+ responsePolicy: values.decodeIfPresent(
+ RadrootsBackgroundTransferResponsePolicy.self, forKey: .responsePolicy
+ ) ?? .discard,
+ expectedSourceSHA256: values.decodeIfPresent(String.self, forKey: .expectedSourceSHA256),
+ maximumTransferBytes: values.decodeIfPresent(UInt64.self, forKey: .maximumTransferBytes)
+ ?? Self.defaultMaximumTransferBytes
+ )
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var values = encoder.container(keyedBy: CodingKeys.self)
+ try values.encode(identifier, forKey: .identifier)
+ try values.encode(remoteURL, forKey: .remoteURL)
+ try values.encode(method, forKey: .method)
+ try values.encode(operation, forKey: .operation)
+ try values.encode(metadata, forKey: .metadata)
+ try values.encode(networkPolicy, forKey: .networkPolicy)
+ try values.encode(responsePolicy, forKey: .responsePolicy)
+ try values.encodeIfPresent(expectedSourceSHA256, forKey: .expectedSourceSHA256)
+ try values.encode(maximumTransferBytes, forKey: .maximumTransferBytes)
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferResponse.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferResponse.swift
@@ -0,0 +1,102 @@
+import Foundation
+
+public struct RadrootsBackgroundTransferResponsePolicy: Sendable, Equatable, Hashable, Codable {
+ public let maximumBodyBytes: Int
+ public let acceptedMediaTypes: [String]
+
+ public init(maximumBodyBytes: Int = 0, acceptedMediaTypes: [String] = []) throws {
+ guard (0 ... 65536).contains(maximumBodyBytes), acceptedMediaTypes.count <= 8 else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ let normalized = try acceptedMediaTypes.map {
+ try RadrootsBackgroundTransferValidation.normalizedMediaType($0)
+ }
+ guard Set(normalized).count == normalized.count, (maximumBodyBytes == 0) == normalized.isEmpty
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ self.maximumBodyBytes = maximumBodyBytes
+ self.acceptedMediaTypes = normalized
+ }
+
+ public static let discard = Self(maximumBodyBytes: 0, acceptedMediaTypes: [], validated: ())
+
+ public static func boundedJSON(maximumBodyBytes: Int = 16384) throws -> Self {
+ try Self(maximumBodyBytes: maximumBodyBytes, acceptedMediaTypes: ["application/json"])
+ }
+
+ private init(maximumBodyBytes: Int, acceptedMediaTypes: [String], validated _: Void) {
+ self.maximumBodyBytes = maximumBodyBytes
+ self.acceptedMediaTypes = acceptedMediaTypes
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case maximumBodyBytes
+ case acceptedMediaTypes
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ try self.init(
+ maximumBodyBytes: values.decode(Int.self, forKey: .maximumBodyBytes),
+ acceptedMediaTypes: values.decode([String].self, forKey: .acceptedMediaTypes)
+ )
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var values = encoder.container(keyedBy: CodingKeys.self)
+ try values.encode(maximumBodyBytes, forKey: .maximumBodyBytes)
+ try values.encode(acceptedMediaTypes, forKey: .acceptedMediaTypes)
+ }
+}
+
+public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, Codable {
+ public let statusCode: Int
+ public let mediaType: String?
+ public let contentEncoding: String?
+ public let body: Data?
+
+ public init(
+ statusCode: Int, mediaType: String?, contentEncoding: String? = nil, body: Data?
+ ) throws {
+ guard (100 ... 599).contains(statusCode), body?.count ?? 0 <= 65536 else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ self.statusCode = statusCode
+ self.mediaType = try mediaType.map {
+ try RadrootsBackgroundTransferValidation.normalizedMediaType($0)
+ }
+ let normalizedEncoding = contentEncoding?.trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ guard normalizedEncoding == nil || normalizedEncoding == "identity" else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ self.contentEncoding = normalizedEncoding
+ self.body = body
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case statusCode
+ case mediaType
+ case contentEncoding
+ case body
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ try self.init(
+ statusCode: values.decode(Int.self, forKey: .statusCode),
+ mediaType: values.decodeIfPresent(String.self, forKey: .mediaType),
+ contentEncoding: values.decodeIfPresent(String.self, forKey: .contentEncoding),
+ body: values.decodeIfPresent(Data.self, forKey: .body)
+ )
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var values = encoder.container(keyedBy: CodingKeys.self)
+ try values.encode(statusCode, forKey: .statusCode)
+ try values.encodeIfPresent(mediaType, forKey: .mediaType)
+ try values.encodeIfPresent(contentEncoding, forKey: .contentEncoding)
+ try values.encodeIfPresent(body, forKey: .body)
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferSnapshot.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferSnapshot.swift
@@ -0,0 +1,134 @@
+import Foundation
+
+public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, Codable,
+ CustomDebugStringConvertible {
+ public let identifier: RadrootsBackgroundTransferIdentifier
+ public let request: RadrootsBackgroundTransferRequest
+ public let state: RadrootsBackgroundTransferState
+ public let progress: RadrootsBackgroundTransferProgress
+ public let failure: RadrootsBackgroundTransferFailure?
+ public let response: RadrootsBackgroundTransferResponse?
+ public let downloadedArtifact: RadrootsBackgroundDownloadedArtifact?
+ public let possibleRemoteOrphan: Bool
+ public let updatedAt: Date
+ public let executionID: UUID?
+ public let uploadLease: RadrootsStagedBlobReference?
+
+ public init(
+ request: RadrootsBackgroundTransferRequest, state: RadrootsBackgroundTransferState = .queued,
+ progress: RadrootsBackgroundTransferProgress = .zero,
+ failure: RadrootsBackgroundTransferFailure? = nil,
+ response: RadrootsBackgroundTransferResponse? = nil,
+ downloadedArtifact: RadrootsBackgroundDownloadedArtifact? = nil,
+ possibleRemoteOrphan: Bool = false, updatedAt: Date = Date(),
+ executionID: UUID? = nil, uploadLease: RadrootsStagedBlobReference? = nil
+ ) throws {
+ guard updatedAt.timeIntervalSinceReferenceDate.isFinite else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ if let uploadLease {
+ guard executionID != nil, request.isUpload,
+ uploadLease.blobID.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil,
+ uploadLease.sizeBytes >= 0, UInt64(uploadLease.sizeBytes) <= request.maximumTransferBytes,
+ request.expectedSourceSHA256 == nil || request.expectedSourceSHA256 == uploadLease.blobID
+ else { throw RadrootsBackgroundTransferError.invalidRequest }
+ }
+ guard response == nil || state == .awaitingVerification || state == .completed else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ if let response {
+ try RadrootsBackgroundTransferValidation.validate(
+ response: response, policy: request.responsePolicy
+ )
+ }
+ if possibleRemoteOrphan {
+ guard case .upload = request.operation,
+ state == .failed || state == .interrupted || state == .cancelled || state == .expired
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+ if let downloadedArtifact {
+ guard case let .download(destination) = request.operation,
+ destination == .file(downloadedArtifact.file),
+ state == .awaitingVerification || state == .completed
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+ identifier = request.identifier
+ self.request = request
+ self.state = state
+ self.progress = progress
+ self.failure = failure
+ self.response = response
+ self.downloadedArtifact = downloadedArtifact
+ self.possibleRemoteOrphan = possibleRemoteOrphan
+ self.updatedAt = updatedAt
+ self.executionID = executionID
+ self.uploadLease = uploadLease
+ }
+
+ public var debugDescription: String {
+ let statusCode = response?.statusCode.description ?? "none"
+ return "RadrootsBackgroundTransferSnapshot(identifier: \(identifier.rawValue), state: \(state.rawValue), "
+ + "bytesTransferred: \(progress.bytesTransferred), statusCode: \(statusCode), "
+ + "downloadedArtifact: \(downloadedArtifact == nil ? "none" : "present"), "
+ + "possibleRemoteOrphan: \(possibleRemoteOrphan))"
+ }
+
+ func redactedForPersistence() throws -> Self {
+ try Self(
+ request: request.redactedForPersistence(), state: state, progress: progress,
+ failure: failure, response: response,
+ downloadedArtifact: downloadedArtifact, possibleRemoteOrphan: possibleRemoteOrphan,
+ updatedAt: updatedAt, executionID: executionID, uploadLease: uploadLease
+ )
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case request
+ case state
+ case progress
+ case failure
+ case response
+ case downloadedArtifact
+ case possibleRemoteOrphan
+ case updatedAt
+ case executionID
+ case uploadLease
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ try self.init(
+ request: values.decode(RadrootsBackgroundTransferRequest.self, forKey: .request),
+ state: values.decode(RadrootsBackgroundTransferState.self, forKey: .state),
+ progress: values.decode(RadrootsBackgroundTransferProgress.self, forKey: .progress),
+ failure: values.decodeIfPresent(RadrootsBackgroundTransferFailure.self, forKey: .failure),
+ response: values.decodeIfPresent(RadrootsBackgroundTransferResponse.self, forKey: .response),
+ downloadedArtifact: values.decodeIfPresent(
+ RadrootsBackgroundDownloadedArtifact.self, forKey: .downloadedArtifact
+ ),
+ possibleRemoteOrphan: values.decodeIfPresent(Bool.self, forKey: .possibleRemoteOrphan)
+ ?? false,
+ updatedAt: values.decode(Date.self, forKey: .updatedAt),
+ executionID: values.decodeIfPresent(UUID.self, forKey: .executionID),
+ uploadLease: values.decodeIfPresent(RadrootsStagedBlobReference.self, forKey: .uploadLease)
+ )
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var values = encoder.container(keyedBy: CodingKeys.self)
+ try values.encode(request, forKey: .request)
+ try values.encode(state, forKey: .state)
+ try values.encode(progress, forKey: .progress)
+ try values.encodeIfPresent(failure, forKey: .failure)
+ try values.encodeIfPresent(response, forKey: .response)
+ try values.encodeIfPresent(downloadedArtifact, forKey: .downloadedArtifact)
+ try values.encode(possibleRemoteOrphan, forKey: .possibleRemoteOrphan)
+ try values.encode(updatedAt, forKey: .updatedAt)
+ try values.encodeIfPresent(executionID, forKey: .executionID)
+ try values.encodeIfPresent(uploadLease, forKey: .uploadLease)
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferTransition.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferTransition.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+extension RadrootsBackgroundTransferSnapshot {
+ func transitioned(
+ to state: RadrootsBackgroundTransferState, at date: Date,
+ failure: RadrootsBackgroundTransferFailure? = nil,
+ possibleRemoteOrphan: Bool = false
+ ) throws -> Self {
+ try Self(
+ request: request, state: state, progress: progress, failure: failure,
+ response: state == .completed ? response : nil,
+ downloadedArtifact: state == .completed ? downloadedArtifact : nil,
+ possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: date,
+ executionID: executionID, uploadLease: uploadLease
+ )
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferValidation.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferValidation.swift
@@ -0,0 +1,210 @@
+import Foundation
+
+public enum RadrootsBackgroundTransferValidation {
+ public static func normalizedIdentifier(_ value: String) throws -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !trimmed.isEmpty else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ guard trimmed.count <= 128 else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ guard
+ trimmed.range(of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", options: .regularExpression)
+ != nil
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ guard !trimmed.contains("..") else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ return trimmed
+ }
+
+ public static func validate(
+ remoteURL: URL, method: RadrootsBackgroundTransferMethod,
+ operation: RadrootsBackgroundTransferOperation, headers: [String: String],
+ metadata: [String: String], networkPolicy: RadrootsBackgroundTransferNetworkPolicy,
+ responsePolicy: RadrootsBackgroundTransferResponsePolicy, expectedSourceSHA256: String?,
+ maximumTransferBytes: UInt64
+ ) throws {
+ try validate(remoteURL: remoteURL, networkPolicy: networkPolicy)
+ try validate(method: method, operation: operation)
+ try validate(headers: headers)
+ try validate(metadata: metadata)
+ guard maximumTransferBytes > 0,
+ maximumTransferBytes <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ if case .download = operation, responsePolicy != .discard {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ if let expectedSourceSHA256 {
+ guard case .upload = operation,
+ expectedSourceSHA256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+ if case let .upload(.stagedBlob(blob)) = operation,
+ UInt64(blob.sizeBytes) > maximumTransferBytes {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+
+ private static func validate(
+ remoteURL: URL, networkPolicy: RadrootsBackgroundTransferNetworkPolicy
+ ) throws {
+ guard let components = URLComponents(url: remoteURL, resolvingAgainstBaseURL: false),
+ components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
+ components.user == nil,
+ components.password == nil, components.query == nil, components.fragment == nil
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ let scheme = components.scheme?.lowercased()
+ switch networkPolicy {
+ case .publicHTTPS:
+ guard scheme == "https" else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ case .simulatorLoopbackHTTP:
+ #if os(iOS) && !targetEnvironment(simulator)
+ throw RadrootsBackgroundTransferError.invalidRequest
+ #else
+ guard scheme == "http", let host = components.host?.lowercased(),
+ host == "localhost" || host == "127.0.0.1" || host == "::1"
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ #endif
+ }
+ }
+
+ private static func validate(
+ method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation
+ ) throws {
+ switch operation {
+ case let .download(destination):
+ guard method == .get else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ guard case .file = destination else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ try validateLocalFile(destination)
+ case let .upload(source):
+ guard method == .post || method == .put else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ try validateLocalFile(source)
+ }
+ }
+
+ static func validateLocalFile(_ localFile: RadrootsBackgroundTransferLocalFile) throws {
+ switch localFile {
+ case let .file(reference):
+ let path = reference.relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !path.isEmpty, !NSString(string: path).isAbsolutePath,
+ !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." })
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ case let .stagedBlob(blob):
+ guard
+ (try? RadrootsStagedBlobReference(
+ blobID: blob.blobID, sizeBytes: blob.sizeBytes, mediaType: blob.mediaType,
+ filenameHint: blob.filenameHint
+ )) != nil
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+ }
+
+ private static func validate(headers: [String: String]) throws {
+ guard headers.count <= 32 else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ for (key, value) in headers {
+ try validateSafeText(key, field: "background transfer header name", maximumLength: 80)
+ guard key.range(of: "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$", options: .regularExpression) != nil,
+ !["connection", "content-length", "host", "transfer-encoding"].contains(key.lowercased())
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ try validateSafeText(value, field: "background transfer header value", maximumLength: 8192)
+ }
+ }
+
+ private static func validate(metadata: [String: String]) throws {
+ guard metadata.count <= 32 else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ for (key, value) in metadata {
+ try validateSafeText(key, field: "background transfer metadata key", maximumLength: 80)
+ try validateSafeText(value, field: "background transfer metadata value", maximumLength: 500)
+ let unsafeKey = key.lowercased()
+ guard
+ !["auth", "token", "secret", "credential", "cookie", "header", "path", "url"].contains(
+ where: { unsafeKey.contains($0) }
+ )
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+ }
+
+ static func normalizedMediaType(_ value: String) throws -> String {
+ let normalized =
+ value.split(separator: ";", maxSplits: 1).first?.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ ).lowercased() ?? ""
+ guard
+ normalized.range(of: "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", options: .regularExpression)
+ != nil,
+ normalized.utf8.count <= 127
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ return normalized
+ }
+
+ static func validate(
+ response: RadrootsBackgroundTransferResponse, policy: RadrootsBackgroundTransferResponsePolicy
+ ) throws {
+ guard (200 ... 299).contains(response.statusCode) else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ if policy.maximumBodyBytes == 0 {
+ guard response.body == nil, response.mediaType == nil else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ return
+ }
+ guard let body = response.body, body.count <= policy.maximumBodyBytes,
+ let mediaType = response.mediaType,
+ policy.acceptedMediaTypes.contains(mediaType)
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+
+ private static func validateSafeText(_ value: String, field _: String, maximumLength: Int) throws {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ guard trimmed.count <= maximumLength else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ guard doesNotContainControlCharacters(trimmed) else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+
+ private static func doesNotContainControlCharacters(_ value: String) -> Bool {
+ value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) }
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferValues.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferValues.swift
@@ -0,0 +1,122 @@
+import Foundation
+
+public struct RadrootsBackgroundDownloadedArtifact: Sendable, Equatable, Hashable, Codable,
+ CustomDebugStringConvertible {
+ public let file: RadrootsFileReference
+ public let sha256: String
+ public let byteSize: UInt64
+ public let mediaType: String?
+
+ public init(file: RadrootsFileReference, sha256: String, byteSize: UInt64, mediaType: String?)
+ throws {
+ guard sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, byteSize > 0,
+ byteSize <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes
+ else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ try RadrootsBackgroundTransferValidation.validateLocalFile(.file(file))
+ self.file = file
+ self.sha256 = sha256
+ self.byteSize = byteSize
+ self.mediaType = try mediaType.map {
+ try RadrootsBackgroundTransferValidation.normalizedMediaType($0)
+ }
+ }
+
+ public var debugDescription: String {
+ "RadrootsBackgroundDownloadedArtifact(sha256: \(sha256), byteSize: \(byteSize), "
+ + "mediaType: \(mediaType ?? "none"), file: <redacted>)"
+ }
+}
+
+public enum RadrootsBackgroundTransferVerification: Sendable, Equatable {
+ case accepted
+ case rejected(failure: RadrootsBackgroundTransferFailure)
+}
+
+public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, Codable,
+ CustomDebugStringConvertible {
+ public let identifier: RadrootsBackgroundTransferIdentifier
+ public let request: RadrootsBackgroundTransferRequest
+
+ public init(request: RadrootsBackgroundTransferRequest) {
+ identifier = request.identifier
+ self.request = request
+ }
+
+ public var debugDescription: String {
+ "RadrootsBackgroundTransferHandle(identifier: \(identifier.rawValue), request: \(request.debugDescription))"
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case identifier
+ case request
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ let identifier = try values.decode(
+ RadrootsBackgroundTransferIdentifier.self, forKey: .identifier
+ )
+ let request = try values.decode(RadrootsBackgroundTransferRequest.self, forKey: .request)
+ guard identifier == request.identifier else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ self.init(request: request)
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var values = encoder.container(keyedBy: CodingKeys.self)
+ try values.encode(identifier, forKey: .identifier)
+ try values.encode(request, forKey: .request)
+ }
+}
+
+public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, Codable {
+ public let bytesTransferred: Int64
+ public let totalBytesExpected: Int64?
+
+ public init(bytesTransferred: Int64, totalBytesExpected: Int64? = nil) throws {
+ guard bytesTransferred >= 0 else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ if let totalBytesExpected {
+ guard totalBytesExpected >= 0 else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ guard totalBytesExpected >= bytesTransferred else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ }
+ self.bytesTransferred = bytesTransferred
+ self.totalBytesExpected = totalBytesExpected
+ }
+
+ public static let zero = RadrootsBackgroundTransferProgress(
+ validatedBytesTransferred: 0, totalBytesExpected: nil
+ )
+
+ private init(validatedBytesTransferred: Int64, totalBytesExpected: Int64?) {
+ bytesTransferred = validatedBytesTransferred
+ self.totalBytesExpected = totalBytesExpected
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case bytesTransferred
+ case totalBytesExpected
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ try self.init(
+ bytesTransferred: values.decode(Int64.self, forKey: .bytesTransferred),
+ totalBytesExpected: values.decodeIfPresent(Int64.self, forKey: .totalBytesExpected)
+ )
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var values = encoder.container(keyedBy: CodingKeys.self)
+ try values.encode(bytesTransferred, forKey: .bytesTransferred)
+ try values.encodeIfPresent(totalBytesExpected, forKey: .totalBytesExpected)
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsBackgroundURLTaskDescriptor.swift b/Sources/RadrootsKit/RadrootsBackgroundURLTaskDescriptor.swift
@@ -0,0 +1,101 @@
+import Foundation
+
+enum RadrootsStagedBackgroundDownloadResult: Sendable, Equatable {
+ case file(URL)
+ case failure
+}
+
+struct RadrootsBackgroundHTTPResult: Sendable, Equatable {
+ let statusCode: Int?
+ let mediaType: String?
+ let body: Data?
+ let contentEncoding: String?
+ let bodyExceeded: Bool
+ let mediaTypeWasMalformed: Bool
+
+ init(
+ statusCode: Int?, mediaType: String?, body: Data?, contentEncoding: String? = nil,
+ bodyExceeded: Bool,
+ mediaTypeWasMalformed: Bool = false
+ ) {
+ self.statusCode = statusCode
+ self.mediaType = mediaType
+ self.body = body
+ self.contentEncoding = contentEncoding
+ self.bodyExceeded = bodyExceeded
+ self.mediaTypeWasMalformed = mediaTypeWasMalformed
+ }
+}
+
+struct RadrootsBackgroundURLTaskDescriptor: Sendable, Equatable {
+ private static let prefix = "radroots-transfer-v1"
+
+ let identifier: RadrootsBackgroundTransferIdentifier
+ let maximumTransferBytes: UInt64
+ let maximumResponseBodyBytes: Int
+ let executionID: UUID?
+
+ init(request: RadrootsBackgroundTransferRequest, executionID: UUID? = nil) {
+ identifier = request.identifier
+ maximumTransferBytes = request.maximumTransferBytes
+ maximumResponseBodyBytes = request.responsePolicy.maximumBodyBytes
+ self.executionID = executionID
+ }
+
+ init?(taskDescription: String?) {
+ guard let taskDescription else { return nil }
+ let components = taskDescription.split(separator: "|", omittingEmptySubsequences: false)
+ if components.count == 5, components[0] == "radroots-transfer-v2",
+ let executionID = UUID(uuidString: String(components[4])),
+ let previous = Self(taskDescription: ([Self.prefix] + components[1 ... 3].map(String.init))
+ .joined(separator: "|")) {
+ identifier = previous.identifier
+ maximumTransferBytes = previous.maximumTransferBytes
+ maximumResponseBodyBytes = previous.maximumResponseBodyBytes
+ self.executionID = executionID
+ return
+ }
+ if components.count == 4,
+ components[0] == Substring(Self.prefix),
+ let identifier = try? RadrootsBackgroundTransferIdentifier(String(components[1])),
+ let maximumTransferBytes = UInt64(components[2]),
+ let maximumResponseBodyBytes = Int(components[3]),
+ maximumTransferBytes > 0,
+ maximumTransferBytes <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes,
+ (0 ... 65536).contains(maximumResponseBodyBytes) {
+ self.identifier = identifier
+ self.maximumTransferBytes = maximumTransferBytes
+ self.maximumResponseBodyBytes = maximumResponseBodyBytes
+ executionID = nil
+ return
+ }
+ guard let legacyIdentifier = try? RadrootsBackgroundTransferIdentifier(taskDescription) else {
+ return nil
+ }
+ identifier = legacyIdentifier
+ maximumTransferBytes = RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes
+ maximumResponseBodyBytes = 65536
+ executionID = nil
+ }
+
+ var taskDescription: String {
+ if let executionID {
+ return "radroots-transfer-v2|\(identifier.rawValue)|\(maximumTransferBytes)|"
+ + "\(maximumResponseBodyBytes)|\(executionID.uuidString.lowercased())"
+ }
+ return "\(Self.prefix)|\(identifier.rawValue)|\(maximumTransferBytes)|\(maximumResponseBodyBytes)"
+ }
+}
+
+extension RadrootsBackgroundTransferRequest {
+ var isUpload: Bool {
+ if case .upload = operation {
+ return true
+ }
+ return false
+ }
+}
+
+struct RadrootsTransferResponseError: Error {
+ let failure: RadrootsBackgroundTransferFailure
+}
diff --git a/Sources/RadrootsKit/RadrootsStagedBlobLease.swift b/Sources/RadrootsKit/RadrootsStagedBlobLease.swift
@@ -22,6 +22,25 @@ public struct RadrootsStagedBlobLease: Sendable, Equatable, CustomDebugStringCon
}
extension RadrootsAppleFileAccess {
+ func leaseUploadBytes(_ bytes: Data, identifier: String) throws -> RadrootsStagedBlobLease {
+ let digest = RadrootsAppleFileDigest.sha256(bytes)
+ let blob = try RadrootsStagedBlobReference(blobID: digest, sizeBytes: bytes.count)
+ let url = try leaseURL(identifier)
+ let lease = RadrootsStagedBlobLease(identifier: identifier, blob: blob, sha256: digest, fileURL: url)
+ do {
+ try RadrootsAtomicFile.install(bytes, at: url, mode: .create, readOnly: true)
+ } catch {
+ try validateLease(lease)
+ try RadrootsAtomicFile.synchronizeExisting(at: url)
+ }
+ try validateLease(lease)
+ return lease
+ }
+
+ func releaseUploadLease(executionID: UUID) throws {
+ try RadrootsAtomicFile.remove(at: leaseURL(executionID.uuidString.lowercased()))
+ }
+
/// Installs an exact reference without removing a prior file first. Matching
/// installs are idempotent. An opaque ID cannot replace different bytes;
/// a SHA256 ID can repair corrupted bytes only with its verified preimage.
@@ -93,7 +112,9 @@ extension RadrootsAppleFileAccess {
guard try leaseURL(lease.identifier) == lease.fileURL else { throw RadrootsAppleFileError.invalidRequest }
do {
try validateLease(lease)
- } catch RadrootsAppleFileError.notFound { return }
+ } catch RadrootsAppleFileError.notFound {
+ return
+ }
try RadrootsAtomicFile.remove(at: lease.fileURL)
}
@@ -114,7 +135,9 @@ extension RadrootsAppleFileAccess {
)
} catch RadrootsGovernedFileReadError.unavailable {
throw RadrootsAppleFileError.notFound
- } catch { throw RadrootsAppleFileError.permanentFailure }
+ } catch {
+ throw RadrootsAppleFileError.permanentFailure
+ }
guard bytes.count == lease.blob.sizeBytes, RadrootsAppleFileDigest.sha256(bytes) == lease.sha256 else {
throw RadrootsAppleFileError.permanentFailure
}
diff --git a/Sources/RadrootsKit/RadrootsTransferCompletion.swift b/Sources/RadrootsKit/RadrootsTransferCompletion.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+struct RadrootsTransferCompletion: Sendable {
+ let platformError: (any Error)?
+ let stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?
+ let httpResult: RadrootsBackgroundHTTPResult
+ let bytesTransferred: Int64
+ let totalBytesExpected: Int64?
+}
diff --git a/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift b/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift
@@ -2,203 +2,226 @@ import Foundation
import RadrootsKit
public actor RadrootsInMemoryBackgroundTransferStore: RadrootsBackgroundTransferStore {
+ private var admissions: Set<RadrootsBackgroundTransferIdentifier> = []
+
+ public func withAdmission(
+ for identifier: RadrootsBackgroundTransferIdentifier,
+ operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle
+ ) async throws -> RadrootsBackgroundTransferHandle {
+ guard admissions.insert(identifier).inserted else { throw RadrootsBackgroundTransferError.invalidRequest }
+ defer { admissions.remove(identifier) }
+ return try await operation()
+ }
+
+ public func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool {
+ admissions.contains(identifier)
+ }
+
private var snapshotsByIdentifier: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot]
- public init(snapshots: [RadrootsBackgroundTransferSnapshot] = []) {
- snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) })
- }
+ public func compareExchangeSnapshot(
+ expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot
+ ) async throws -> Bool {
+ guard expected == nil || expected?.identifier == desired.identifier else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ guard snapshotsByIdentifier[desired.identifier] == expected else { return false }
+ snapshotsByIdentifier[desired.identifier] = desired
+ return true
+ }
- public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
- snapshotsByIdentifier.values.sorted { left, right in
- left.identifier < right.identifier
+ public init(snapshots: [RadrootsBackgroundTransferSnapshot] = []) {
+ snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) })
}
- }
- public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws {
- snapshotsByIdentifier[snapshot.identifier] = snapshot
- }
+ public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ snapshotsByIdentifier.values.sorted { left, right in
+ left.identifier < right.identifier
+ }
+ }
- public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws {
- snapshotsByIdentifier.removeValue(forKey: identifier)
- }
+ public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws {
+ snapshotsByIdentifier[snapshot.identifier] = snapshot
+ }
+
+ public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ snapshotsByIdentifier.removeValue(forKey: identifier)
+ }
- public func removeAllSnapshots() async throws {
- snapshotsByIdentifier.removeAll()
- }
+ public func removeAllSnapshots() async throws {
+ snapshotsByIdentifier.removeAll()
+ }
}
public actor RadrootsFakeBackgroundTransfer: RadrootsBackgroundTransfer {
- private let store: any RadrootsBackgroundTransferStore
- private var enqueueOutcome: Result<Void, RadrootsBackgroundTransferError>
- private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest]
- private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier]
- private var handledBackgroundEventIdentifiersValue: [String]
- private let updatedAt: Date
-
- public init(
- store: any RadrootsBackgroundTransferStore = RadrootsInMemoryBackgroundTransferStore(),
- enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()),
- updatedAt: Date = Date(timeIntervalSince1970: 0)
- ) {
- self.store = store
- self.enqueueOutcome = enqueueOutcome
- enqueuedRequestsValue = []
- cancelledIdentifiersValue = []
- handledBackgroundEventIdentifiersValue = []
- self.updatedAt = updatedAt
- }
-
- public func setEnqueueOutcome(_ outcome: Result<Void, RadrootsBackgroundTransferError>) {
- enqueueOutcome = outcome
- }
-
- public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- {
- enqueuedRequestsValue.append(request)
- switch enqueueOutcome {
- case .success:
- let snapshot = try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: .queued,
- updatedAt: updatedAt
- )
- try await store.saveSnapshot(snapshot)
- return RadrootsBackgroundTransferHandle(request: request)
- case .failure(let error):
- throw error
- }
- }
-
- public func retry(_ request: RadrootsBackgroundTransferRequest) async throws
- -> RadrootsBackgroundTransferHandle
- {
- try await enqueue(request)
- }
-
- public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
- cancelledIdentifiersValue.append(identifier)
+ private let store: any RadrootsBackgroundTransferStore
+ private var enqueueOutcome: Result<Void, RadrootsBackgroundTransferError>
+ private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest]
+ private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier]
+ private var handledBackgroundEventIdentifiersValue: [String]
+ private let updatedAt: Date
+
+ public init(
+ store: any RadrootsBackgroundTransferStore = RadrootsInMemoryBackgroundTransferStore(),
+ enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()),
+ updatedAt: Date = Date(timeIntervalSince1970: 0)
+ ) {
+ self.store = store
+ self.enqueueOutcome = enqueueOutcome
+ enqueuedRequestsValue = []
+ cancelledIdentifiersValue = []
+ handledBackgroundEventIdentifiersValue = []
+ self.updatedAt = updatedAt
+ }
+
+ public func setEnqueueOutcome(_ outcome: Result<Void, RadrootsBackgroundTransferError>) {
+ enqueueOutcome = outcome
+ }
+
+ public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws
+ -> RadrootsBackgroundTransferHandle {
+ enqueuedRequestsValue.append(request)
+ switch enqueueOutcome {
+ case .success:
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .queued,
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
+ return RadrootsBackgroundTransferHandle(request: request)
+ case let .failure(error):
+ throw error
+ }
+ }
+
+ public func retry(_ request: RadrootsBackgroundTransferRequest) async throws
+ -> RadrootsBackgroundTransferHandle {
+ try await enqueue(request)
+ }
+
+ public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ cancelledIdentifiersValue.append(identifier)
if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) {
- let snapshot = try RadrootsBackgroundTransferSnapshot(
- request: existing.request,
- state: .cancelled,
- progress: existing.progress,
- updatedAt: updatedAt
- )
- try await store.saveSnapshot(snapshot)
- }
- }
-
- public func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
- cancelledIdentifiersValue.append(identifier)
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .cancelled,
+ progress: existing.progress,
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
+ }
+ }
+
+ public func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ cancelledIdentifiersValue.append(identifier)
if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) {
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: existing.request,
- state: .expired,
- progress: existing.progress,
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .expired,
+ progress: existing.progress,
failure: .expired,
- updatedAt: updatedAt
- )
- )
+ updatedAt: updatedAt
+ )
+ )
+ }
}
- }
- public func settle(
- _ identifier: RadrootsBackgroundTransferIdentifier,
- verification: RadrootsBackgroundTransferVerification
- ) async throws {
- guard
- let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier })
- else {
+ public func settle(
+ _ identifier: RadrootsBackgroundTransferIdentifier,
+ verification: RadrootsBackgroundTransferVerification
+ ) async throws {
+ guard
+ let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier })
+ else {
throw RadrootsBackgroundTransferError.transferFailure
- }
- switch verification {
- case .accepted:
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: existing.request,
- state: .completed,
- progress: existing.progress,
- response: existing.response,
- downloadedArtifact: existing.downloadedArtifact,
- updatedAt: updatedAt
- )
- )
- case .rejected(let failure):
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: existing.request,
- state: .failed,
- progress: existing.progress,
+ }
+ switch verification {
+ case .accepted:
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .completed,
+ progress: existing.progress,
+ response: existing.response,
+ downloadedArtifact: existing.downloadedArtifact,
+ updatedAt: updatedAt
+ )
+ )
+ case let .rejected(failure):
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .failed,
+ progress: existing.progress,
failure: failure,
- updatedAt: updatedAt
- )
- )
+ updatedAt: updatedAt
+ )
+ )
+ }
}
- }
- public func complete(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
- guard
- let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier })
- else {
+ public func complete(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ guard
+ let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier })
+ else {
throw RadrootsBackgroundTransferError.transferFailure
+ }
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .completed,
+ progress: existing.progress,
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
}
- let snapshot = try RadrootsBackgroundTransferSnapshot(
- request: existing.request,
- state: .completed,
- progress: existing.progress,
- updatedAt: updatedAt
- )
- try await store.saveSnapshot(snapshot)
- }
public func fail(
_ identifier: RadrootsBackgroundTransferIdentifier,
failure: RadrootsBackgroundTransferFailure
) async throws {
- guard
- let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier })
- else {
+ guard
+ let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier })
+ else {
throw RadrootsBackgroundTransferError.transferFailure
- }
- let snapshot = try RadrootsBackgroundTransferSnapshot(
- request: existing.request,
- state: .failed,
- progress: existing.progress,
+ }
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .failed,
+ progress: existing.progress,
failure: failure,
- updatedAt: updatedAt
- )
- try await store.saveSnapshot(snapshot)
- }
-
- public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
- -> RadrootsBackgroundTransferSnapshot?
- {
- try await store.loadSnapshots().first { $0.identifier == identifier }
- }
-
- public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
- try await store.loadSnapshots()
- }
-
- public func handleEventsForBackgroundURLSession(
- identifier: String,
- completionHandler: @escaping @Sendable () -> Void
- ) async {
- handledBackgroundEventIdentifiersValue.append(identifier)
- completionHandler()
- }
-
- public var enqueuedRequests: [RadrootsBackgroundTransferRequest] {
- enqueuedRequestsValue
- }
-
- public var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] {
- cancelledIdentifiersValue
- }
-
- public var handledBackgroundEventIdentifiers: [String] {
- handledBackgroundEventIdentifiersValue
- }
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
+ }
+
+ public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
+ -> RadrootsBackgroundTransferSnapshot? {
+ try await store.loadSnapshots().first { $0.identifier == identifier }
+ }
+
+ public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ try await store.loadSnapshots()
+ }
+
+ public func handleEventsForBackgroundURLSession(
+ identifier: String,
+ completionHandler: @escaping @Sendable () -> Void
+ ) async {
+ handledBackgroundEventIdentifiersValue.append(identifier)
+ completionHandler()
+ }
+
+ public var enqueuedRequests: [RadrootsBackgroundTransferRequest] {
+ enqueuedRequestsValue
+ }
+
+ public var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] {
+ cancelledIdentifiersValue
+ }
+
+ public var handledBackgroundEventIdentifiers: [String] {
+ handledBackgroundEventIdentifiersValue
+ }
}
diff --git a/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift
@@ -1,835 +1,285 @@
import Darwin
import Foundation
+@testable import RadrootsKit
import RadrootsKitTesting
import Testing
-@testable import RadrootsKit
-
@Test func appleBackgroundTransferPersistsRunningSnapshotAfterEnqueue() async throws {
- let store = RadrootsInMemoryBackgroundTransferStore()
- let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 100))
- let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
- let request = try appleTransferRequest(identifier: "field.transfer.enqueue")
-
- let handle = try await transfer.enqueue(request)
-
- #expect(handle.identifier == request.identifier)
- #expect(await probe.enqueuedRequests == [request])
- let snapshot = try await transfer.snapshot(for: request.identifier)
- #expect(snapshot?.state == .running)
- #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 100))
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 100))
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+ let request = try appleTransferRequest(identifier: "field.transfer.enqueue")
+
+ let handle = try await transfer.enqueue(request)
+
+ #expect(handle.identifier == request.identifier)
+ #expect(await probe.enqueuedRequests == [request])
+ let snapshot = try await transfer.snapshot(for: request.identifier)
+ #expect(snapshot?.state == .running)
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 100))
}
@Test func appleBackgroundTransferRejectsDuplicateIdentifiersBeforeAdapterMutation() async throws {
- let store = RadrootsInMemoryBackgroundTransferStore()
- let probe = RadrootsAppleBackgroundTransferProbe()
- let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
- let request = try appleTransferRequest(identifier: "field.transfer.duplicate")
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let probe = RadrootsAppleBackgroundTransferProbe()
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+ let request = try appleTransferRequest(identifier: "field.transfer.duplicate")
- _ = try await transfer.enqueue(request)
- await #expect(
- throws: RadrootsBackgroundTransferError.invalidRequest
- ) {
_ = try await transfer.enqueue(request)
- }
+ await #expect(
+ throws: RadrootsBackgroundTransferError.invalidRequest
+ ) {
+ _ = try await transfer.enqueue(request)
+ }
- #expect(await probe.enqueuedRequests == [request])
+ #expect(await probe.enqueuedRequests == [request])
}
@Test func appleBackgroundTransferRecordsFailedSnapshotWhenAdapterRejectsEnqueue() async throws {
- let store = RadrootsInMemoryBackgroundTransferStore()
- let probe = RadrootsAppleBackgroundTransferProbe(
- now: Date(timeIntervalSince1970: 200),
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let probe = RadrootsAppleBackgroundTransferProbe(
+ now: Date(timeIntervalSince1970: 200),
enqueueOutcome: .failure(.transferFailure)
- )
- let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
- let request = try appleTransferRequest(identifier: "field.transfer.failed")
+ )
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+ let request = try appleTransferRequest(identifier: "field.transfer.failed")
- await #expect(
+ await #expect(
throws: RadrootsBackgroundTransferError.transferFailure
- ) {
- _ = try await transfer.enqueue(request)
- }
+ ) {
+ _ = try await transfer.enqueue(request)
+ }
- let snapshot = try await transfer.snapshot(for: request.identifier)
- #expect(snapshot?.state == .failed)
+ let snapshot = try await transfer.snapshot(for: request.identifier)
+ #expect(snapshot?.state == .failed)
#expect(snapshot?.failure == .enqueueFailed)
- #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 200))
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 200))
}
@Test func appleBackgroundTransferCancelsThroughAdapterAndUpdatesStore() async throws {
- let store = RadrootsInMemoryBackgroundTransferStore()
- let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 300))
- let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
- let request = try appleTransferRequest(identifier: "field.transfer.cancel")
-
- _ = try await transfer.enqueue(request)
- try await transfer.cancel(request.identifier)
-
- #expect(await probe.cancelledIdentifiers == [request.identifier])
- #expect(try await transfer.snapshot(for: request.identifier)?.state == .cancelled)
- #expect(
- try await transfer.snapshot(for: request.identifier)?.updatedAt
- == Date(timeIntervalSince1970: 300))
-}
-
-@Test func appleBackgroundTransferReconcilesQueuedSnapshotsWithActiveRecoveredTasks() async throws {
- let request = try appleTransferRequest(identifier: "field.transfer.recovered")
- let queued = try RadrootsBackgroundTransferSnapshot(
- request: request, state: .queued, updatedAt: Date(timeIntervalSince1970: 1))
- let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [queued])
- let probe = RadrootsAppleBackgroundTransferProbe(
- now: Date(timeIntervalSince1970: 400), activeIdentifiers: [request.identifier])
- let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
-
- let snapshots = try await transfer.snapshots()
-
- #expect(snapshots.count == 1)
- #expect(snapshots.first?.state == .running)
- #expect(snapshots.first?.updatedAt == Date(timeIntervalSince1970: 400))
- #expect(try await store.loadSnapshots().first?.state == .running)
-}
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 300))
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+ let request = try appleTransferRequest(identifier: "field.transfer.cancel")
-@Test func appleBackgroundTransferMarksMissingRecoveredUploadInterrupted() async throws {
- let request = try appleUploadRequest(identifier: "field.transfer.interrupted")
- let running = try RadrootsBackgroundTransferSnapshot(
- request: request, state: .running,
- progress: RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10),
- updatedAt: Date(timeIntervalSince1970: 1)
- )
- let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [running])
- let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 401))
- let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
-
- let snapshot = try #require(try await transfer.snapshots().first)
-
- #expect(snapshot.state == .interrupted)
- #expect(snapshot.failure == .interrupted)
- #expect(snapshot.possibleRemoteOrphan)
- #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 401))
-}
-
-@Test func appleBackgroundTransferForwardsBackgroundCompletionHandlers() async {
- let probe = RadrootsAppleBackgroundTransferProbe()
- let transfer = RadrootsAppleBackgroundTransfer(
- store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters())
- let completion = RadrootsCompletionProbe()
-
- await transfer.handleEventsForBackgroundURLSession(
- identifier: "org.radroots.field-ios.background.transfer"
- ) {
- completion.markCompleted()
- }
-
- #expect(
- await probe.handledBackgroundEventIdentifiers == ["org.radroots.field-ios.background.transfer"])
- #expect(completion.completed)
-}
-
-@Test func appleBackgroundTransferCoordinatorMovesCompletedDownloadToDestination() async throws {
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: resolver,
- now: { Date(timeIntervalSince1970: 500) }
- )
- let request = try appleTransferRequest(identifier: "field.transfer.completed")
- let running = try RadrootsBackgroundTransferSnapshot(
- request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1))
- try await store.saveSnapshot(running)
- let stagingRoot = roots.temporaryRoot.appendingPathComponent(
- "background-transfer-tests", isDirectory: true)
- try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
- let stagedFile = stagingRoot.appendingPathComponent("download.bin")
- let payload = Data("downloaded".utf8)
- try payload.write(to: stagedFile)
-
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: .file(stagedFile),
- httpResult: RadrootsBackgroundHTTPResult(
- statusCode: 200, mediaType: "image/png", body: nil, bodyExceeded: false),
- bytesTransferred: 0, totalBytesExpected: nil
- )
-
- let snapshot = try await store.loadSnapshots().first
- let destination = try resolver.resolve(
- .file(RadrootsFileReference(scope: .cache, relativePath: "field.transfer.completed.json")))
- #expect(snapshot?.state == .awaitingVerification)
- #expect(snapshot?.progress.bytesTransferred == Int64(payload.count))
- #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 500))
- #expect(snapshot?.response?.statusCode == 200)
- #expect(snapshot?.downloadedArtifact?.byteSize == UInt64(payload.count))
- #expect(snapshot?.downloadedArtifact?.mediaType == "image/png")
- let downloadedDigest = try RadrootsAppleFileDigest.sha256(at: destination)
- #expect(snapshot?.downloadedArtifact?.sha256 == downloadedDigest)
- #expect(try Data(contentsOf: destination) == payload)
- #expect(!FileManager.default.fileExists(atPath: stagedFile.path))
-}
-
-@Test func appleBackgroundTransferCoordinatorRejectsMalformedResponseMediaType() async throws {
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- )
- let request = try appleUploadRequest(identifier: "field.transfer.malformed-media-type")
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(request: request, state: .running))
-
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: nil,
- httpResult: RadrootsBackgroundHTTPResult(
- statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false,
- mediaTypeWasMalformed: true),
- bytesTransferred: 10, totalBytesExpected: 10
- )
-
- let snapshot = try #require(try await store.loadSnapshots().first)
- #expect(snapshot.state == .failed)
- #expect(snapshot.failure == .responseMediaType)
- #expect(snapshot.possibleRemoteOrphan)
-}
+ _ = try await transfer.enqueue(request)
+ try await transfer.cancel(request.identifier)
-@Test func appleBackgroundTransferCoordinatorRecordsFailedDownloadSnapshot() async throws {
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: resolver,
- now: { Date(timeIntervalSince1970: 600) }
- )
- let request = try appleTransferRequest(identifier: "field.transfer.download.failed")
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1))
- )
-
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: .failure,
- httpResult: successfulHTTPResult(),
- bytesTransferred: 0, totalBytesExpected: nil
- )
-
- let snapshot = try await store.loadSnapshots().first
- #expect(snapshot?.state == .failed)
- #expect(snapshot?.failure == .downloadStagingFailure)
- #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 600))
+ #expect(await probe.cancelledIdentifiers == [request.identifier])
+ #expect(try await transfer.snapshot(for: request.identifier)?.state == .cancelled)
+ #expect(
+ try await transfer.snapshot(for: request.identifier)?.updatedAt
+ == Date(timeIntervalSince1970: 300)
+ )
}
-@Test func appleBackgroundTransferCoordinatorCompletesUploadWithProgress() async throws {
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: resolver,
- now: { Date(timeIntervalSince1970: 700) }
- )
- let request = try appleUploadRequest(identifier: "field.transfer.upload.completed")
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(
- request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1))
- )
-
- await coordinator.updateProgress(
- identifier: request.identifier, bytesTransferred: 4, totalBytesExpected: 10)
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: nil,
- httpResult: successfulHTTPResult(),
- bytesTransferred: 10, totalBytesExpected: 10
- )
-
- let snapshot = try await store.loadSnapshots().first
- #expect(snapshot?.state == .awaitingVerification)
- #expect(snapshot?.progress.bytesTransferred == 10)
- #expect(snapshot?.progress.totalBytesExpected == 10)
- #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 700))
- #expect(snapshot?.response?.statusCode == 200)
-}
+@Test func appleBackgroundTransferReconcilesQueuedSnapshotsWithActiveRecoveredTasks() async throws {
+ let request = try appleTransferRequest(identifier: "field.transfer.recovered")
+ let queued = try RadrootsBackgroundTransferSnapshot(
+ request: request, state: .queued, updatedAt: Date(timeIntervalSince1970: 1)
+ )
+ let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [queued])
+ let probe = RadrootsAppleBackgroundTransferProbe(
+ now: Date(timeIntervalSince1970: 400), activeIdentifiers: [request.identifier]
+ )
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
-@Test func appleBackgroundTransferCoordinatorPersistsBoundedDescriptorResponse() async throws {
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots),
- now: { Date(timeIntervalSince1970: 710) }
- )
- let body = Data(#"{"url":"https://cdn.radroots.org/a.png"}"#.utf8)
- let request = try appleUploadRequest(
- identifier: "field.transfer.upload.descriptor",
- responsePolicy: .boundedJSON(maximumBodyBytes: 1024)
- )
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(request: request, state: .running))
-
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: nil,
- httpResult: RadrootsBackgroundHTTPResult(
- statusCode: 200, mediaType: "application/json", body: body, bodyExceeded: false),
- bytesTransferred: 10, totalBytesExpected: 10
- )
-
- let snapshot = try #require(try await store.loadSnapshots().first)
- #expect(snapshot.state == .awaitingVerification)
- #expect(snapshot.response?.body == body)
- #expect(snapshot.response?.mediaType == "application/json")
- #expect(!snapshot.possibleRemoteOrphan)
-
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: nil,
- httpResult: RadrootsBackgroundHTTPResult(
- statusCode: 500, mediaType: nil, body: nil, bodyExceeded: false), bytesTransferred: 10,
- totalBytesExpected: 10
- )
- #expect(try await store.loadSnapshots().first?.state == .awaitingVerification)
-}
+ let snapshots = try await transfer.snapshots()
-@Test func appleBackgroundTransferCoordinatorRejectsEncodedDescriptorResponse() async throws {
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- )
- let request = try appleUploadRequest(
- identifier: "field.transfer.upload.encoded",
- responsePolicy: .boundedJSON(maximumBodyBytes: 1024)
- )
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(request: request, state: .running))
-
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: nil,
- httpResult: RadrootsBackgroundHTTPResult(
- statusCode: 200, mediaType: "application/json", body: Data("{}".utf8),
- contentEncoding: "gzip", bodyExceeded: false),
- bytesTransferred: 10, totalBytesExpected: 10
- )
-
- let snapshot = try #require(try await store.loadSnapshots().first)
- #expect(snapshot.state == .failed)
- #expect(snapshot.failure == .responseContentEncoding)
- #expect(snapshot.possibleRemoteOrphan)
+ #expect(snapshots.count == 1)
+ #expect(snapshots.first?.state == .running)
+ #expect(snapshots.first?.updatedAt == Date(timeIntervalSince1970: 400))
+ #expect(try await store.loadSnapshots().first?.state == .running)
}
-@Test func appleBackgroundTransferCoordinatorRejectsStatusAndOversizedResponse() async throws {
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots),
- now: { Date(timeIntervalSince1970: 720) }
- )
- let statusRequest = try appleUploadRequest(identifier: "field.transfer.upload.status")
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(request: statusRequest, state: .running))
- await coordinator.complete(
- identifier: statusRequest.identifier, platformError: nil, stagedDownloadResult: nil,
- httpResult: RadrootsBackgroundHTTPResult(
- statusCode: 503, mediaType: nil, body: nil, bodyExceeded: false), bytesTransferred: 10,
- totalBytesExpected: 10
- )
- #expect(
- try await store.loadSnapshots().first?.failure == .httpStatus)
-
- let bodyRequest = try appleUploadRequest(
- identifier: "field.transfer.upload.oversized",
- responsePolicy: .boundedJSON(maximumBodyBytes: 32)
- )
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(request: bodyRequest, state: .running))
- await coordinator.complete(
- identifier: bodyRequest.identifier, platformError: CancellationError(),
- stagedDownloadResult: nil,
- httpResult: RadrootsBackgroundHTTPResult(
- statusCode: 200, mediaType: "application/json", body: nil, bodyExceeded: true),
- bytesTransferred: 10, totalBytesExpected: 10
- )
- let oversized = try #require(
- try await store.loadSnapshots().first { $0.identifier == bodyRequest.identifier })
- #expect(oversized.state == .failed)
- #expect(oversized.failure == .responseTooLarge)
- #expect(oversized.possibleRemoteOrphan)
-}
+@Test func appleBackgroundTransferMarksMissingRecoveredUploadInterrupted() async throws {
+ let request = try appleUploadRequest(identifier: "field.transfer.interrupted")
+ let running = try RadrootsBackgroundTransferSnapshot(
+ request: request, state: .running,
+ progress: RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10),
+ updatedAt: Date(timeIntervalSince1970: 1)
+ )
+ let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [running])
+ let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 401))
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
-@Test func appleBackgroundTransferCoordinatorDoesNotResurrectCancelledTransfer() async throws {
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- )
- let request = try appleUploadRequest(identifier: "field.transfer.upload.cancelled")
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(request: request, state: .cancelled))
-
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: nil,
- httpResult: successfulHTTPResult(),
- bytesTransferred: 10, totalBytesExpected: 10
- )
-
- #expect(try await store.loadSnapshots().first?.state == .cancelled)
-}
+ let snapshot = try #require(try await transfer.snapshots().first)
-@Test func appleBackgroundTransferRecoversCompletionLostWhileProtectedDataIsLocked() async throws {
- let roots = try appleTransferRoots()
- let protectedData = RadrootsProtectedDataProbe(state: .available)
- let store = RadrootsAppleBackgroundTransferStore(
- roots: roots, protectedData: RadrootsProtectedDataProvider { protectedData.state })
- let request = try appleUploadRequest(identifier: "field.transfer.upload.locked")
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(request: request, state: .running))
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- )
-
- protectedData.state = .locked
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: nil,
- httpResult: successfulHTTPResult(),
- bytesTransferred: 10, totalBytesExpected: 10
- )
- protectedData.state = .available
-
- let transfer = RadrootsAppleBackgroundTransfer(
- store: store, adapters: RadrootsAppleBackgroundTransferProbe().adapters())
- let recovered = try #require(try await transfer.snapshots().first)
- #expect(recovered.state == .interrupted)
- #expect(recovered.possibleRemoteOrphan)
+ #expect(snapshot.state == .interrupted)
+ #expect(snapshot.failure == .interrupted)
+ #expect(snapshot.possibleRemoteOrphan)
+ #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 401))
}
-@Test func appleBackgroundTransferCoordinatorInvokesStoredCompletionHandlerAfterFinishedEvents()
- async throws
-{
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: resolver
- )
- let completion = RadrootsCompletionProbe()
- let secondCompletion = RadrootsCompletionProbe()
- let unrelated = RadrootsCompletionProbe()
-
- await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") {
- completion.markCompleted()
- }
- #expect(!completion.completed)
- await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") {
- secondCompletion.markCompleted()
- }
-
- await coordinator.handleBackgroundEvents(identifier: "other.session") {
- unrelated.markCompleted()
- }
- #expect(unrelated.completed)
-
- await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer")
- #expect(completion.completed)
- #expect(secondCompletion.completed)
- await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer")
- #expect(completion.completionCount == 1)
- #expect(secondCompletion.completionCount == 1)
-}
+@Test func appleBackgroundTransferForwardsBackgroundCompletionHandlers() async {
+ let probe = RadrootsAppleBackgroundTransferProbe()
+ let transfer = RadrootsAppleBackgroundTransfer(
+ store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters()
+ )
+ let completion = RadrootsCompletionProbe()
-@Test func appleBackgroundTransferCoordinatorClaimsFinishBeforeHandlerExactlyOnce() async throws {
- let roots = try appleTransferRoots()
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer",
- store: RadrootsInMemoryBackgroundTransferStore(),
- fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- )
- let completion = RadrootsCompletionProbe()
-
- await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer")
- await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") {
+ await transfer.handleEventsForBackgroundURLSession(
+ identifier: "org.radroots.field-ios.background.transfer"
+ ) {
completion.markCompleted()
}
- #expect(completion.completionCount == 1)
-}
-
-@Test func appleBackgroundTransferRequiresExplicitVerificationSettlement() async throws {
- let request = try appleUploadRequest(identifier: "field.transfer.settlement")
- let response = try RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil)
- let store = RadrootsInMemoryBackgroundTransferStore(
- snapshots: [
- try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: .awaitingVerification,
- response: response
- )
- ]
- )
- let transfer = RadrootsAppleBackgroundTransfer(
- store: store,
- adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 800)).adapters()
- )
-
- try await transfer.settle(request.identifier, verification: .accepted)
-
- #expect(try await store.loadSnapshots().first?.state == .completed)
- #expect(try await store.loadSnapshots().first?.response == response)
- await #expect(
- throws: RadrootsBackgroundTransferError.invalidRequest
- ) {
- try await transfer.settle(request.identifier, verification: .accepted)
- }
-}
-
-@Test func appleBackgroundTransferRejectedVerificationRemainsRecoverableWithoutClaimingSuccess()
- async throws
-{
- let request = try appleUploadRequest(identifier: "field.transfer.rejected")
- let store = RadrootsInMemoryBackgroundTransferStore(
- snapshots: [
- try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: .awaitingVerification,
- response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil)
- )
- ]
- )
- let transfer = RadrootsAppleBackgroundTransfer(
- store: store,
- adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 850)).adapters()
- )
-
- try await transfer.settle(
- request.identifier,
- verification: .rejected(failure: .verificationRejected)
- )
-
- let snapshot = try #require(try await store.loadSnapshots().first)
- #expect(snapshot.state == .failed)
- #expect(snapshot.failure == .verificationRejected)
- #expect(snapshot.possibleRemoteOrphan)
- #expect(snapshot.response == nil)
-}
-
-@Test func appleBackgroundTransferExpirationAndRetryPreserveStableIdentity() async throws {
- let request = try appleUploadRequest(identifier: "field.transfer.expire-retry")
- let store = RadrootsInMemoryBackgroundTransferStore()
- let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 900))
- let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
-
- _ = try await transfer.enqueue(request)
- try await transfer.expire(request.identifier)
- #expect(try await store.loadSnapshots().first?.state == .expired)
- #expect(await probe.cancelledIdentifiers == [request.identifier])
-
- let retried = try await transfer.retry(request)
- #expect(retried.identifier == request.identifier)
- #expect(try await store.loadSnapshots().first?.state == .running)
- #expect(await probe.enqueuedRequests == [request, request])
-}
-
-@Test func appleBackgroundTransferRefusesRetryWhilePlatformTaskIsStillActive() async throws {
- let request = try appleUploadRequest(identifier: "field.transfer.active-retry")
- let store = RadrootsInMemoryBackgroundTransferStore(
- snapshots: [try RadrootsBackgroundTransferSnapshot(request: request, state: .interrupted)]
- )
- let probe = RadrootsAppleBackgroundTransferProbe(activeIdentifiers: [request.identifier])
- let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
-
- await #expect(
- throws: RadrootsBackgroundTransferError.invalidRequest
- ) {
- _ = try await transfer.retry(request)
- }
- #expect(await probe.enqueuedRequests.isEmpty)
- #expect(try await store.loadSnapshots().first?.state == .interrupted)
-}
-
-@Test func appleBackgroundTransferTerminalCancellationIsIdempotent() async throws {
- let request = try appleUploadRequest(identifier: "field.transfer.terminal-cancel")
- let store = RadrootsInMemoryBackgroundTransferStore(
- snapshots: [
- try RadrootsBackgroundTransferSnapshot(request: request, state: .awaitingVerification)
- ]
- )
- let probe = RadrootsAppleBackgroundTransferProbe()
- let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
-
- try await transfer.cancel(request.identifier)
- try await transfer.expire(request.identifier)
-
- #expect(try await store.loadSnapshots().first?.state == .awaitingVerification)
- #expect(await probe.cancelledIdentifiers.isEmpty)
-}
-
-@Test func appleBackgroundTransferCoordinatorRejectsTransfersBeyondImmutableBound() async throws {
- let roots = try appleTransferRoots()
- let store = RadrootsInMemoryBackgroundTransferStore()
- let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
- fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- )
- let request = try appleUploadRequest(
- identifier: "field.transfer.too-large", maximumTransferBytes: 5)
- try await store.saveSnapshot(
- RadrootsBackgroundTransferSnapshot(request: request, state: .running))
-
- await coordinator.complete(
- identifier: request.identifier, platformError: nil, stagedDownloadResult: nil,
- httpResult: successfulHTTPResult(),
- bytesTransferred: 6, totalBytesExpected: 6
- )
-
- let snapshot = try #require(try await store.loadSnapshots().first)
- #expect(snapshot.state == .failed)
- #expect(snapshot.failure == .transferTooLarge)
- #expect(snapshot.possibleRemoteOrphan)
-}
-
-@Test func appleBackgroundURLTaskDescriptorRetainsRelaunchBoundsAndMigratesLegacyIdentity() throws {
- let request = try appleUploadRequest(
- identifier: "field.transfer.task-descriptor",
- responsePolicy: .boundedJSON(maximumBodyBytes: 1_024),
- maximumTransferBytes: 4_096
- )
-
- let descriptor = RadrootsBackgroundURLTaskDescriptor(request: request)
- #expect(
- RadrootsBackgroundURLTaskDescriptor(taskDescription: descriptor.taskDescription) == descriptor)
-
- let legacy = try #require(
- RadrootsBackgroundURLTaskDescriptor(taskDescription: request.identifier.rawValue))
- #expect(legacy.identifier == request.identifier)
- #expect(
- legacy.maximumTransferBytes == RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes)
- #expect(legacy.maximumResponseBodyBytes == 65_536)
- #expect(
- RadrootsBackgroundURLTaskDescriptor(taskDescription: "radroots-transfer-v1|unsafe|0|0") == nil)
+ #expect(
+ await probe.handledBackgroundEventIdentifiers == ["org.radroots.field-ios.background.transfer"]
+ )
+ #expect(completion.completed)
}
-#if os(iOS) && targetEnvironment(simulator)
- @Test func appleBackgroundTransferLiveURLSessionDownloadsIntoRustVerificationBoundary()
- async throws
- {
- guard let origin = ProcessInfo.processInfo.environment["RADROOTS_URLSESSION_TEST_ORIGIN"],
- let remoteURL = URL(string: origin)?.appendingPathComponent("Package.swift")
- else { return }
+@Test func appleBackgroundTransferCoordinatorMovesCompletedDownloadToDestination() async throws {
let roots = try appleTransferRoots()
- defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) }
+ let store = RadrootsInMemoryBackgroundTransferStore()
let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
- let transfer = try RadrootsAppleBackgroundTransfer(
- roots: roots,
- sessionIdentifier: "org.radroots.tests.background-transfer.\(UUID().uuidString.lowercased())"
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: resolver,
+ now: { Date(timeIntervalSince1970: 500) }
)
- let destination = RadrootsFileReference(
- scope: .cache, relativePath: "live-urlsession/package.swift")
- let request = try RadrootsBackgroundTransferRequest(
- identifier: RadrootsBackgroundTransferIdentifier("field.transfer.live-urlsession"),
- remoteURL: remoteURL,
- method: .get,
- operation: .download(destination: .file(destination)),
- networkPolicy: .simulatorLoopbackHTTP,
- maximumTransferBytes: 64 * 1_024
+ let request = try appleTransferRequest(identifier: "field.transfer.completed")
+ let running = try RadrootsBackgroundTransferSnapshot(
+ request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)
+ )
+ try await store.saveSnapshot(running)
+ let stagingRoot = roots.temporaryRoot.appendingPathComponent(
+ "background-transfer-tests", isDirectory: true
+ )
+ try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
+ let stagedFile = stagingRoot.appendingPathComponent("download.bin")
+ let payload = Data("downloaded".utf8)
+ try payload.write(to: stagedFile)
+
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: .file(stagedFile),
+ httpResult: RadrootsBackgroundHTTPResult(
+ statusCode: 200, mediaType: "image/png", body: nil, bodyExceeded: false
+ ),
+ bytesTransferred: 0,
+ totalBytesExpected: nil
+ )
)
- _ = try await transfer.enqueue(request)
- let deadline = Date().addingTimeInterval(15)
- var snapshot = try await transfer.snapshot(for: request.identifier)
- while snapshot?.state == .queued || snapshot?.state == .running {
- guard Date() < deadline else {
- throw RadrootsBackgroundTransferError.transferFailure
- }
- try await Task.sleep(nanoseconds: 50_000_000)
- snapshot = try await transfer.snapshot(for: request.identifier)
- }
-
- let completedTransfer = try #require(snapshot)
- #expect(completedTransfer.state == .awaitingVerification)
- let downloadedArtifact = try #require(completedTransfer.downloadedArtifact)
- let destinationURL = try resolver.resolve(.file(destination))
- let downloadedBytes = try Data(contentsOf: destinationURL)
- #expect(!downloadedBytes.isEmpty)
- #expect(downloadedArtifact.byteSize == UInt64(downloadedBytes.count))
- #expect(downloadedArtifact.sha256 == (try RadrootsAppleFileDigest.sha256(at: destinationURL)))
- #expect(downloadedArtifact.mediaType == "application/octet-stream")
-
- try await transfer.settle(request.identifier, verification: .accepted)
- #expect(try await transfer.snapshot(for: request.identifier)?.state == .completed)
- }
-#endif
-
-@Test func appleBackgroundTransferCancelsRecoveredPlatformOrphans() async throws {
- let identifier = try RadrootsBackgroundTransferIdentifier("field.transfer.platform-orphan")
- let probe = RadrootsAppleBackgroundTransferProbe(activeIdentifiers: [identifier])
- let transfer = RadrootsAppleBackgroundTransfer(
- store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters())
-
- #expect(try await transfer.snapshots().isEmpty)
- #expect(await probe.cancelledIdentifiers == [identifier])
+ let snapshot = try await store.loadSnapshots().first
+ let destination = try resolver.resolve(
+ .file(RadrootsFileReference(scope: .cache, relativePath: "field.transfer.completed.json"))
+ )
+ #expect(snapshot?.state == .awaitingVerification)
+ #expect(snapshot?.progress.bytesTransferred == Int64(payload.count))
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 500))
+ #expect(snapshot?.response?.statusCode == 200)
+ #expect(snapshot?.downloadedArtifact?.byteSize == UInt64(payload.count))
+ #expect(snapshot?.downloadedArtifact?.mediaType == "image/png")
+ let downloadedDigest = try RadrootsAppleFileDigest.sha256(at: destination)
+ #expect(snapshot?.downloadedArtifact?.sha256 == downloadedDigest)
+ #expect(try Data(contentsOf: destination) == payload)
+ #expect(!FileManager.default.fileExists(atPath: stagedFile.path))
}
-private actor RadrootsAppleBackgroundTransferProbe {
- private let nowValue: Date
- private let enqueueOutcome: Result<Void, RadrootsBackgroundTransferError>
- private var activeIdentifiersValue: Set<RadrootsBackgroundTransferIdentifier>
- private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest]
- private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier]
- private var handledBackgroundEventIdentifiersValue: [String]
-
- init(
- now: Date = Date(timeIntervalSince1970: 0),
- enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()),
- activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> = []
- ) {
- nowValue = now
- self.enqueueOutcome = enqueueOutcome
- activeIdentifiersValue = activeIdentifiers
- enqueuedRequestsValue = []
- cancelledIdentifiersValue = []
- handledBackgroundEventIdentifiersValue = []
- }
-
- nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters {
- RadrootsAppleBackgroundTransferAdapters(
- now: { self.nowValue }, enqueue: { request in try await self.enqueue(request) },
- cancel: { identifier in await self.cancel(identifier) },
- activeTransferIdentifiers: { await self.activeIdentifiers() },
- handleBackgroundEvents: { identifier, completionHandler in
- await self.handleBackgroundEvents(
- identifier: identifier, completionHandler: completionHandler)
- }
+@Test func appleBackgroundTransferCoordinatorRejectsMalformedResponseMediaType() async throws {
+ let roots = try appleTransferRoots()
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ )
+ let request = try appleUploadRequest(identifier: "field.transfer.malformed-media-type")
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(request: request, state: .running)
)
- }
- private func enqueue(_ request: RadrootsBackgroundTransferRequest) throws {
- enqueuedRequestsValue.append(request)
- switch enqueueOutcome {
- case .success: activeIdentifiersValue.insert(request.identifier)
- case .failure(let error): throw error
- }
- }
-
- private func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) {
- cancelledIdentifiersValue.append(identifier)
- activeIdentifiersValue.remove(identifier)
- }
-
- private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> {
- activeIdentifiersValue
- }
-
- private func handleBackgroundEvents(
- identifier: String, completionHandler: @escaping @Sendable () -> Void
- ) {
- handledBackgroundEventIdentifiersValue.append(identifier)
- completionHandler()
- }
-
- var enqueuedRequests: [RadrootsBackgroundTransferRequest] {
- enqueuedRequestsValue
- }
-
- var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] {
- cancelledIdentifiersValue
- }
-
- var handledBackgroundEventIdentifiers: [String] {
- handledBackgroundEventIdentifiersValue
- }
-}
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: nil,
+ httpResult: RadrootsBackgroundHTTPResult(
+ statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false,
+ mediaTypeWasMalformed: true
+ ),
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+ )
-private final class RadrootsCompletionProbe: @unchecked Sendable {
- private let lock = NSLock()
- private var completionCountValue = 0
-
- func markCompleted() {
- lock.lock()
- completionCountValue += 1
- lock.unlock()
- }
-
- var completed: Bool {
- completionCount > 0
- }
-
- var completionCount: Int {
- lock.lock()
- defer { lock.unlock() }
- return completionCountValue
- }
+ let snapshot = try #require(try await store.loadSnapshots().first)
+ #expect(snapshot.state == .failed)
+ #expect(snapshot.failure == .responseMediaType)
+ #expect(snapshot.possibleRemoteOrphan)
}
-private final class RadrootsProtectedDataProbe: @unchecked Sendable {
- private let lock = NSLock()
- private var stateValue: RadrootsProtectedDataState
-
- init(state: RadrootsProtectedDataState) {
- stateValue = state
- }
+@Test func appleBackgroundTransferCoordinatorRecordsFailedDownloadSnapshot() async throws {
+ let roots = try appleTransferRoots()
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: resolver,
+ now: { Date(timeIntervalSince1970: 600) }
+ )
+ let request = try appleTransferRequest(identifier: "field.transfer.download.failed")
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(
+ request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)
+ )
+ )
- var state: RadrootsProtectedDataState {
- get {
- lock.lock()
- defer { lock.unlock() }
- return stateValue
- }
- set {
- lock.lock()
- stateValue = newValue
- lock.unlock()
- }
- }
-}
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: .failure,
+ httpResult: successfulHTTPResult(),
+ bytesTransferred: 0,
+ totalBytesExpected: nil
+ )
+ )
-private func appleTransferRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest {
- try RadrootsBackgroundTransferRequest(
- identifier: RadrootsBackgroundTransferIdentifier(identifier),
- remoteURL: URL(string: "https://radroots.org/\(identifier).json")!,
- method: .get,
- operation: .download(
- destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")))
- )
+ let snapshot = try await store.loadSnapshots().first
+ #expect(snapshot?.state == .failed)
+ #expect(snapshot?.failure == .downloadStagingFailure)
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 600))
}
-private func appleUploadRequest(
- identifier: String,
- responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard,
- maximumTransferBytes: UInt64 = RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes
-) throws
- -> RadrootsBackgroundTransferRequest
-{
- try RadrootsBackgroundTransferRequest(
- identifier: RadrootsBackgroundTransferIdentifier(identifier),
- remoteURL: URL(string: "https://radroots.org/\(identifier).json")!,
- method: .put,
- operation: .upload(
- source: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))),
- responsePolicy: responsePolicy, maximumTransferBytes: maximumTransferBytes
- )
-}
+@Test func appleBackgroundTransferCoordinatorCompletesUploadWithProgress() async throws {
+ let roots = try appleTransferRoots()
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: resolver,
+ now: { Date(timeIntervalSince1970: 700) }
+ )
+ let request = try appleUploadRequest(identifier: "field.transfer.upload.completed")
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(
+ request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)
+ )
+ )
-private func successfulHTTPResult() -> RadrootsBackgroundHTTPResult {
- RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false)
-}
+ await coordinator.updateProgress(
+ identifier: request.identifier, bytesTransferred: 4, totalBytesExpected: 10
+ )
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: nil,
+ httpResult: successfulHTTPResult(),
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+ )
-private func appleTransferRoots() throws -> RadrootsAppleFileRoots {
- let unresolvedRoot = FileManager.default.temporaryDirectory.appendingPathComponent(
- "radroots-apple-background-transfer-\(UUID().uuidString)", isDirectory: true
- )
- try FileManager.default.createDirectory(at: unresolvedRoot, withIntermediateDirectories: false)
- guard let resolvedPointer = unresolvedRoot.path.withCString({ Darwin.realpath($0, nil) }) else {
- throw RadrootsBackgroundTransferError.persistenceFailure
- }
- defer { Darwin.free(resolvedPointer) }
- let root = URL(fileURLWithPath: String(cString: resolvedPointer), isDirectory: true)
- return try RadrootsAppleFileRoots(
- appIdentifier: "org.radroots.tests",
- dataRoot: root.appendingPathComponent("data", isDirectory: true),
- cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
- temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)
- )
+ let snapshot = try await store.loadSnapshots().first
+ #expect(snapshot?.state == .awaitingVerification)
+ #expect(snapshot?.progress.bytesTransferred == 10)
+ #expect(snapshot?.progress.totalBytesExpected == 10)
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 700))
+ #expect(snapshot?.response?.statusCode == 200)
}
diff --git a/Tests/RadrootsKitTests/RadrootsAppleErrorSafetyTests.swift b/Tests/RadrootsKitTests/RadrootsAppleErrorSafetyTests.swift
@@ -1,96 +1,28 @@
import Foundation
+@testable import RadrootsKit
import RadrootsKitTesting
import Testing
-@testable import RadrootsKit
-
@Test func publicAppleErrorsUseClosedPathFreeDescriptions() {
- let errors: [any Error] = [
- RadrootsCaptureIntakeError.invalidRequest,
- RadrootsCaptureIntakeError.unavailable,
- RadrootsCaptureIntakeError.permissionDenied,
- RadrootsCaptureIntakeError.userCancelled,
- RadrootsCaptureIntakeError.transientFailure,
- RadrootsCaptureIntakeError.permanentFailure,
- RadrootsBackgroundTransferError.invalidRequest,
- RadrootsBackgroundTransferError.unavailable,
- RadrootsBackgroundTransferError.transferFailure,
- RadrootsBackgroundTransferError.persistenceFailure,
- RadrootsDocumentInterchangeError.invalidRequest,
- RadrootsDocumentInterchangeError.notFound,
- RadrootsDocumentInterchangeError.userCancelled,
- RadrootsDocumentInterchangeError.permissionDenied,
- RadrootsDocumentInterchangeError.transientFailure,
- RadrootsDocumentInterchangeError.permanentFailure,
- RadrootsAppLocalStateResetError.invalidRequest,
- RadrootsAppLocalStateResetError.fileSystemFailure,
- RadrootsAppLocalStateResetError.keychainFailure,
- RadrootsAppleMediaPreparationError.invalidRequest,
- RadrootsAppleMediaPreparationError.unavailable,
- RadrootsAppleMediaPreparationError.preparationFailure,
- RadrootsTelemetryError.invalidRequest,
- RadrootsExternalActionError.invalidRequest,
- RadrootsExternalActionError.blockedByPolicy,
- RadrootsExternalActionError.unavailable,
- RadrootsExternalActionError.transientFailure,
- RadrootsExternalActionError.permanentFailure,
- RadrootsAppleFileError.invalidRequest,
- RadrootsAppleFileError.notFound,
- RadrootsAppleFileError.permissionDenied,
- RadrootsAppleFileError.transientFailure,
- RadrootsAppleFileError.permanentFailure,
- RadrootsLocationServicesError.invalidRequest,
- RadrootsLocationServicesError.permissionDenied,
- RadrootsLocationServicesError.unavailable,
- RadrootsLocationServicesError.timeout,
- RadrootsLocationServicesError.cancelled,
- RadrootsLocationServicesError.transientFailure,
- RadrootsLocationServicesError.permanentFailure,
- RadrootsUserPresenceError.invalidRequest,
- RadrootsUserPresenceError.userCancelled,
- RadrootsUserPresenceError.permissionDenied,
- RadrootsUserPresenceError.unavailable,
- RadrootsUserPresenceError.timeout,
- RadrootsUserPresenceError.transientFailure,
- RadrootsUserPresenceError.permanentFailure,
- RadrootsBackgroundTaskError.invalidRequest,
- RadrootsBackgroundTaskError.unavailable,
- RadrootsBackgroundTaskError.schedulerFailure,
- RadrootsAppleSecurityError.invalidRequest,
- RadrootsAppleSecurityError.notFound,
- RadrootsAppleSecurityError.permissionDenied,
- RadrootsAppleSecurityError.userCancelled,
- RadrootsAppleSecurityError.transientFailure,
- RadrootsAppleSecurityError.unavailable,
- RadrootsAppleSecurityError.permanentFailure,
- RadrootsAppleSecurityError.keychainFailure,
- RadrootsAppleMobileStoreError.invalidPublicKey,
- RadrootsAppleMobileStoreError.protectedDataUnavailable,
- RadrootsAppleMobileStoreError.invalidDirectoryLayout,
- RadrootsAppleMobileStoreError.fileSystemFailure,
- RadrootsVerifiedArtifactAccessError.invalidDescriptor,
- RadrootsVerifiedArtifactAccessError.protectedDataUnavailable,
- RadrootsVerifiedArtifactAccessError.artifactUnavailable,
- RadrootsVerifiedArtifactAccessError.artifactCorrupt,
- RadrootsVerifiedArtifactAccessError.fileSystemFailure,
- ]
+ let errors = publicAppleErrorExamples
let forbidden = [
"/Users/example/private.sqlite",
"https://secret.example.invalid/token",
- "nsec1secretcanary",
+ "nsec1secretcanary"
]
for error in errors {
let renderings = [
String(describing: error),
String(reflecting: error),
- (error as NSError).localizedDescription,
+ (error as NSError).localizedDescription
]
#expect(renderings.allSatisfy { !$0.isEmpty })
#expect(
renderings.allSatisfy { rendering in
forbidden.allSatisfy { !rendering.contains($0) }
- })
+ }
+ )
}
}
@@ -118,6 +50,154 @@ import Testing
}
@Test func throwingAppleAdaptersMapDependencyDiagnosticsToClosedErrors() async throws {
+ try await verifyBackgroundTaskErrorMapping()
+ try await verifyPermissionErrorMapping()
+ try await verifyTransferErrorMapping()
+}
+
+@Test func fileSystemDiagnosticsCannotEscapeTheClosedFileErrorBoundary() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("radroots-error-safety-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let invalidDataRoot = root.appendingPathComponent("private.sqlite")
+ try Data("not a directory".utf8).write(to: invalidDataRoot)
+ let roots = try RadrootsAppleFileRoots(
+ appIdentifier: "org.radroots.error-safety",
+ dataRoot: invalidDataRoot,
+ cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
+ temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true)
+ )
+ let access = RadrootsAppleFileAccess(roots: roots)
+
+ do {
+ try access.write(
+ .inline(Data("payload".utf8)),
+ to: RadrootsFileReference(scope: .data, relativePath: "secret.example.invalid")
+ )
+ Issue.record("expected the invalid filesystem layout to fail")
+ } catch let error as RadrootsAppleFileError {
+ #expect(error == .permanentFailure)
+ #expect(!String(reflecting: error).contains("private.sqlite"))
+ #expect(!(error.errorDescription ?? "").contains("secret.example.invalid"))
+ } catch {
+ Issue.record("unexpected dependency-owned error: \(type(of: error))")
+ }
+}
+
+private func dependencyCanaryError() -> NSError {
+ NSError(
+ domain: "/Users/example/private.sqlite",
+ code: 9,
+ userInfo: [NSLocalizedDescriptionKey: "https://secret.example.invalid/token"]
+ )
+}
+
+private actor ThrowingBackgroundTransferStore: RadrootsBackgroundTransferStore {
+ func withAdmission(
+ for _: RadrootsBackgroundTransferIdentifier,
+ operation _: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle
+ ) async throws -> RadrootsBackgroundTransferHandle {
+ throw dependencyCanaryError()
+ }
+
+ func admissionIsActive(for _: RadrootsBackgroundTransferIdentifier) async throws -> Bool {
+ throw dependencyCanaryError()
+ }
+
+ func compareExchangeSnapshot(
+ expected _: RadrootsBackgroundTransferSnapshot?, desired _: RadrootsBackgroundTransferSnapshot
+ ) async throws -> Bool {
+ throw dependencyCanaryError()
+ }
+
+ func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ throw dependencyCanaryError()
+ }
+
+ func saveSnapshot(_: RadrootsBackgroundTransferSnapshot) async throws {
+ throw dependencyCanaryError()
+ }
+
+ func removeSnapshot(for _: RadrootsBackgroundTransferIdentifier) async throws {
+ throw dependencyCanaryError()
+ }
+
+ func removeAllSnapshots() async throws {
+ throw dependencyCanaryError()
+ }
+}
+
+private let publicAppleErrorExamples: [any Error] = [
+ RadrootsCaptureIntakeError.invalidRequest,
+ RadrootsCaptureIntakeError.unavailable,
+ RadrootsCaptureIntakeError.permissionDenied,
+ RadrootsCaptureIntakeError.userCancelled,
+ RadrootsCaptureIntakeError.transientFailure,
+ RadrootsCaptureIntakeError.permanentFailure,
+ RadrootsBackgroundTransferError.invalidRequest,
+ RadrootsBackgroundTransferError.unavailable,
+ RadrootsBackgroundTransferError.transferFailure,
+ RadrootsBackgroundTransferError.persistenceFailure,
+ RadrootsDocumentInterchangeError.invalidRequest,
+ RadrootsDocumentInterchangeError.notFound,
+ RadrootsDocumentInterchangeError.userCancelled,
+ RadrootsDocumentInterchangeError.permissionDenied,
+ RadrootsDocumentInterchangeError.transientFailure,
+ RadrootsDocumentInterchangeError.permanentFailure,
+ RadrootsAppLocalStateResetError.invalidRequest,
+ RadrootsAppLocalStateResetError.fileSystemFailure,
+ RadrootsAppLocalStateResetError.keychainFailure,
+ RadrootsAppleMediaPreparationError.invalidRequest,
+ RadrootsAppleMediaPreparationError.unavailable,
+ RadrootsAppleMediaPreparationError.preparationFailure,
+ RadrootsTelemetryError.invalidRequest,
+ RadrootsExternalActionError.invalidRequest,
+ RadrootsExternalActionError.blockedByPolicy,
+ RadrootsExternalActionError.unavailable,
+ RadrootsExternalActionError.transientFailure,
+ RadrootsExternalActionError.permanentFailure,
+ RadrootsAppleFileError.invalidRequest,
+ RadrootsAppleFileError.notFound,
+ RadrootsAppleFileError.permissionDenied,
+ RadrootsAppleFileError.transientFailure,
+ RadrootsAppleFileError.permanentFailure,
+ RadrootsLocationServicesError.invalidRequest,
+ RadrootsLocationServicesError.permissionDenied,
+ RadrootsLocationServicesError.unavailable,
+ RadrootsLocationServicesError.timeout,
+ RadrootsLocationServicesError.cancelled,
+ RadrootsLocationServicesError.transientFailure,
+ RadrootsLocationServicesError.permanentFailure,
+ RadrootsUserPresenceError.invalidRequest,
+ RadrootsUserPresenceError.userCancelled,
+ RadrootsUserPresenceError.permissionDenied,
+ RadrootsUserPresenceError.unavailable,
+ RadrootsUserPresenceError.timeout,
+ RadrootsUserPresenceError.transientFailure,
+ RadrootsUserPresenceError.permanentFailure,
+ RadrootsBackgroundTaskError.invalidRequest,
+ RadrootsBackgroundTaskError.unavailable,
+ RadrootsBackgroundTaskError.schedulerFailure,
+ RadrootsAppleSecurityError.invalidRequest,
+ RadrootsAppleSecurityError.notFound,
+ RadrootsAppleSecurityError.permissionDenied,
+ RadrootsAppleSecurityError.userCancelled,
+ RadrootsAppleSecurityError.transientFailure,
+ RadrootsAppleSecurityError.unavailable,
+ RadrootsAppleSecurityError.permanentFailure,
+ RadrootsAppleSecurityError.keychainFailure,
+ RadrootsAppleMobileStoreError.invalidPublicKey,
+ RadrootsAppleMobileStoreError.protectedDataUnavailable,
+ RadrootsAppleMobileStoreError.invalidDirectoryLayout,
+ RadrootsAppleMobileStoreError.fileSystemFailure,
+ RadrootsVerifiedArtifactAccessError.invalidDescriptor,
+ RadrootsVerifiedArtifactAccessError.protectedDataUnavailable,
+ RadrootsVerifiedArtifactAccessError.artifactUnavailable,
+ RadrootsVerifiedArtifactAccessError.artifactCorrupt,
+ RadrootsVerifiedArtifactAccessError.fileSystemFailure
+]
+
+private func verifyBackgroundTaskErrorMapping() async throws {
let backgroundTasks = RadrootsAppleBackgroundTaskScheduler(
adapters: RadrootsAppleBackgroundTaskSchedulerAdapters(
now: { Date(timeIntervalSince1970: 1) },
@@ -139,7 +219,9 @@ import Testing
await #expect(throws: RadrootsBackgroundTaskError.schedulerFailure) {
_ = try await backgroundTasks.pendingTasks()
}
+}
+private func verifyPermissionErrorMapping() async throws {
let location = RadrootsAppleLocationServices(
adapters: RadrootsAppleLocationServicesAdapters(
now: { Date(timeIntervalSince1970: 1) },
@@ -166,7 +248,9 @@ import Testing
await #expect(throws: RadrootsUserPresenceError.permanentFailure) {
_ = try await presence.verify(request)
}
+}
+private func verifyTransferErrorMapping() async throws {
let transferRequest = try RadrootsBackgroundTransferRequest(
identifier: RadrootsBackgroundTransferIdentifier("error-safety.transfer"),
remoteURL: #require(URL(string: "https://radroots.org/error-safety")),
@@ -189,7 +273,7 @@ import Testing
store: RadrootsInMemoryBackgroundTransferStore(),
adapters: RadrootsAppleBackgroundTransferAdapters(
now: { Date(timeIntervalSince1970: 1) },
- enqueue: { _ in throw dependencyCanaryError() },
+ enqueue: { _, _ in throw dependencyCanaryError() },
cancel: { _ in },
activeTransferIdentifiers: { [] },
handleBackgroundEvents: { _, completion in completion() }
@@ -199,58 +283,3 @@ import Testing
_ = try await adapterFailure.enqueue(transferRequest)
}
}
-
-@Test func fileSystemDiagnosticsCannotEscapeTheClosedFileErrorBoundary() throws {
- let root = FileManager.default.temporaryDirectory
- .appendingPathComponent("radroots-error-safety-\(UUID().uuidString)", isDirectory: true)
- try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
- let invalidDataRoot = root.appendingPathComponent("private.sqlite")
- try Data("not a directory".utf8).write(to: invalidDataRoot)
- let roots = try RadrootsAppleFileRoots(
- appIdentifier: "org.radroots.error-safety",
- dataRoot: invalidDataRoot,
- cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
- temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true)
- )
- let access = RadrootsAppleFileAccess(roots: roots)
-
- do {
- try access.write(
- .inline(Data("payload".utf8)),
- to: RadrootsFileReference(scope: .data, relativePath: "secret.example.invalid")
- )
- Issue.record("expected the invalid filesystem layout to fail")
- } catch let error as RadrootsAppleFileError {
- #expect(error == .permanentFailure)
- #expect(!String(reflecting: error).contains("private.sqlite"))
- #expect(!(error.errorDescription ?? "").contains("secret.example.invalid"))
- } catch {
- Issue.record("unexpected dependency-owned error: \(type(of: error))")
- }
-}
-
-private func dependencyCanaryError() -> NSError {
- NSError(
- domain: "/Users/example/private.sqlite",
- code: 9,
- userInfo: [NSLocalizedDescriptionKey: "https://secret.example.invalid/token"]
- )
-}
-
-private actor ThrowingBackgroundTransferStore: RadrootsBackgroundTransferStore {
- func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
- throw dependencyCanaryError()
- }
-
- func saveSnapshot(_: RadrootsBackgroundTransferSnapshot) async throws {
- throw dependencyCanaryError()
- }
-
- func removeSnapshot(for _: RadrootsBackgroundTransferIdentifier) async throws {
- throw dependencyCanaryError()
- }
-
- func removeAllSnapshots() async throws {
- throw dependencyCanaryError()
- }
-}
diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferReceiptTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferReceiptTests.swift
@@ -0,0 +1,314 @@
+import Darwin
+import Foundation
+@testable import RadrootsKit
+import RadrootsKitTesting
+import Testing
+
+@Test func appleBackgroundTransferCoordinatorPersistsBoundedDescriptorResponse() async throws {
+ let roots = try appleTransferRoots()
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots),
+ now: { Date(timeIntervalSince1970: 710) }
+ )
+ let body = Data(#"{"url":"https://cdn.radroots.org/a.png"}"#.utf8)
+ let request = try appleUploadRequest(
+ identifier: "field.transfer.upload.descriptor",
+ responsePolicy: .boundedJSON(maximumBodyBytes: 1024)
+ )
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(request: request, state: .running)
+ )
+
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: nil,
+ httpResult: RadrootsBackgroundHTTPResult(
+ statusCode: 200, mediaType: "application/json", body: body, bodyExceeded: false
+ ),
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+ )
+
+ let snapshot = try #require(try await store.loadSnapshots().first)
+ #expect(snapshot.state == .awaitingVerification)
+ #expect(snapshot.response?.body == body)
+ #expect(snapshot.response?.mediaType == "application/json")
+ #expect(!snapshot.possibleRemoteOrphan)
+
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: nil,
+ httpResult: RadrootsBackgroundHTTPResult(
+ statusCode: 500, mediaType: nil, body: nil, bodyExceeded: false
+ ),
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+ )
+ #expect(try await store.loadSnapshots().first?.state == .awaitingVerification)
+}
+
+@Test func appleBackgroundTransferCoordinatorRejectsEncodedDescriptorResponse() async throws {
+ let roots = try appleTransferRoots()
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ )
+ let request = try appleUploadRequest(
+ identifier: "field.transfer.upload.encoded",
+ responsePolicy: .boundedJSON(maximumBodyBytes: 1024)
+ )
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(request: request, state: .running)
+ )
+
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: nil,
+ httpResult: RadrootsBackgroundHTTPResult(
+ statusCode: 200, mediaType: "application/json", body: Data("{}".utf8),
+ contentEncoding: "gzip", bodyExceeded: false
+ ),
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+ )
+
+ let snapshot = try #require(try await store.loadSnapshots().first)
+ #expect(snapshot.state == .failed)
+ #expect(snapshot.failure == .responseContentEncoding)
+ #expect(snapshot.possibleRemoteOrphan)
+}
+
+@Test func appleBackgroundTransferCoordinatorRejectsStatusAndOversizedResponse() async throws {
+ let roots = try appleTransferRoots()
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots),
+ now: { Date(timeIntervalSince1970: 720) }
+ )
+ let statusRequest = try appleUploadRequest(identifier: "field.transfer.upload.status")
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(request: statusRequest, state: .running)
+ )
+ await coordinator.complete(
+ identifier: statusRequest.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: nil,
+ httpResult: RadrootsBackgroundHTTPResult(
+ statusCode: 503, mediaType: nil, body: nil, bodyExceeded: false
+ ),
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+ )
+ #expect(try await store.loadSnapshots().first?.failure == .httpStatus)
+
+ let bodyRequest = try appleUploadRequest(
+ identifier: "field.transfer.upload.oversized",
+ responsePolicy: .boundedJSON(maximumBodyBytes: 32)
+ )
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(request: bodyRequest, state: .running)
+ )
+ await coordinator.complete(
+ identifier: bodyRequest.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: CancellationError(),
+ stagedDownloadResult: nil,
+ httpResult: RadrootsBackgroundHTTPResult(
+ statusCode: 200, mediaType: "application/json", body: nil, bodyExceeded: true
+ ),
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+ )
+ let oversized = try #require(
+ try await store.loadSnapshots().first { $0.identifier == bodyRequest.identifier }
+ )
+ #expect(oversized.state == .failed)
+ #expect(oversized.failure == .responseTooLarge)
+ #expect(oversized.possibleRemoteOrphan)
+}
+
+@Test func appleBackgroundTransferCoordinatorDoesNotResurrectCancelledTransfer() async throws {
+ let roots = try appleTransferRoots()
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ )
+ let request = try appleUploadRequest(identifier: "field.transfer.upload.cancelled")
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(request: request, state: .cancelled)
+ )
+
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: nil,
+ httpResult: successfulHTTPResult(),
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+ )
+
+ #expect(try await store.loadSnapshots().first?.state == .cancelled)
+}
+
+@Test func appleBackgroundTransferRecoversCompletionLostWhileProtectedDataIsLocked() async throws {
+ let roots = try appleTransferRoots()
+ let protectedData = RadrootsProtectedDataProbe(state: .available)
+ let store = RadrootsAppleBackgroundTransferStore(
+ roots: roots, protectedData: RadrootsProtectedDataProvider { protectedData.state }
+ )
+ let request = try appleUploadRequest(identifier: "field.transfer.upload.locked")
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(request: request, state: .running)
+ )
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ )
+
+ protectedData.state = .locked
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: nil,
+ httpResult: successfulHTTPResult(),
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+ )
+ protectedData.state = .available
+
+ let transfer = RadrootsAppleBackgroundTransfer(
+ store: store, adapters: RadrootsAppleBackgroundTransferProbe().adapters()
+ )
+ let recovered = try #require(try await transfer.snapshots().first)
+ #expect(recovered.state == .interrupted)
+ #expect(recovered.possibleRemoteOrphan)
+}
+
+@Test func appleBackgroundTransferCoordinatorInvokesStoredCompletionHandlerAfterFinishedEvents()
+ async throws {
+ let roots = try appleTransferRoots()
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: resolver
+ )
+ let completion = RadrootsCompletionProbe()
+ let secondCompletion = RadrootsCompletionProbe()
+ let unrelated = RadrootsCompletionProbe()
+
+ await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") {
+ completion.markCompleted()
+ }
+ #expect(!completion.completed)
+ await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") {
+ secondCompletion.markCompleted()
+ }
+
+ await coordinator.handleBackgroundEvents(identifier: "other.session") {
+ unrelated.markCompleted()
+ }
+ #expect(unrelated.completed)
+
+ await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer")
+ #expect(completion.completed)
+ #expect(secondCompletion.completed)
+ await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer")
+ #expect(completion.completionCount == 1)
+ #expect(secondCompletion.completionCount == 1)
+}
+
+@Test func appleBackgroundTransferCoordinatorClaimsFinishBeforeHandlerExactlyOnce() async throws {
+ let roots = try appleTransferRoots()
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer",
+ store: RadrootsInMemoryBackgroundTransferStore(),
+ fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ )
+ let completion = RadrootsCompletionProbe()
+
+ await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer")
+ await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") {
+ completion.markCompleted()
+ }
+
+ #expect(completion.completionCount == 1)
+}
+
+@Test func appleBackgroundTransferRequiresExplicitVerificationSettlement() async throws {
+ let request = try appleUploadRequest(identifier: "field.transfer.settlement")
+ let response = try RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil)
+ let store = try RadrootsInMemoryBackgroundTransferStore(
+ snapshots: [
+ RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .awaitingVerification,
+ response: response
+ )
+ ]
+ )
+ let transfer = RadrootsAppleBackgroundTransfer(
+ store: store,
+ adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 800)).adapters()
+ )
+
+ try await transfer.settle(request.identifier, verification: .accepted)
+
+ #expect(try await store.loadSnapshots().first?.state == .completed)
+ #expect(try await store.loadSnapshots().first?.response == response)
+ await #expect(
+ throws: RadrootsBackgroundTransferError.invalidRequest
+ ) {
+ try await transfer.settle(request.identifier, verification: .accepted)
+ }
+}
+
+@Test func appleBackgroundTransferRejectedVerificationRemainsRecoverableWithoutClaimingSuccess()
+ async throws {
+ let request = try appleUploadRequest(identifier: "field.transfer.rejected")
+ let store = try RadrootsInMemoryBackgroundTransferStore(
+ snapshots: [
+ RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .awaitingVerification,
+ response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil)
+ )
+ ]
+ )
+ let transfer = RadrootsAppleBackgroundTransfer(
+ store: store,
+ adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 850)).adapters()
+ )
+
+ try await transfer.settle(
+ request.identifier,
+ verification: .rejected(failure: .verificationRejected)
+ )
+
+ let snapshot = try #require(try await store.loadSnapshots().first)
+ #expect(snapshot.state == .failed)
+ #expect(snapshot.failure == .verificationRejected)
+ #expect(snapshot.possibleRemoteOrphan)
+ #expect(snapshot.response == nil)
+}
diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferRecoveryTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferRecoveryTests.swift
@@ -0,0 +1,174 @@
+import Darwin
+import Foundation
+@testable import RadrootsKit
+import RadrootsKitTesting
+import Testing
+
+@Test func appleBackgroundTransferExpirationAndRetryPreserveStableIdentity() async throws {
+ let request = try appleUploadRequest(identifier: "field.transfer.expire-retry")
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 900))
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+
+ _ = try await transfer.enqueue(request)
+ try await transfer.expire(request.identifier)
+ #expect(try await store.loadSnapshots().first?.state == .expired)
+ #expect(await probe.cancelledIdentifiers == [request.identifier])
+
+ let retried = try await transfer.retry(request)
+ #expect(retried.identifier == request.identifier)
+ #expect(try await store.loadSnapshots().first?.state == .running)
+ #expect(await probe.enqueuedRequests == [request, request])
+}
+
+@Test func appleBackgroundTransferRefusesRetryWhilePlatformTaskIsStillActive() async throws {
+ let request = try appleUploadRequest(identifier: "field.transfer.active-retry")
+ let store = try RadrootsInMemoryBackgroundTransferStore(
+ snapshots: [RadrootsBackgroundTransferSnapshot(request: request, state: .interrupted)]
+ )
+ let probe = RadrootsAppleBackgroundTransferProbe(activeIdentifiers: [request.identifier])
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+
+ await #expect(
+ throws: RadrootsBackgroundTransferError.invalidRequest
+ ) {
+ _ = try await transfer.retry(request)
+ }
+ #expect(await probe.enqueuedRequests.isEmpty)
+ #expect(try await store.loadSnapshots().first?.state == .interrupted)
+}
+
+@Test func appleBackgroundTransferTerminalCancellationIsIdempotent() async throws {
+ let request = try appleUploadRequest(identifier: "field.transfer.terminal-cancel")
+ let store = try RadrootsInMemoryBackgroundTransferStore(
+ snapshots: [
+ RadrootsBackgroundTransferSnapshot(request: request, state: .awaitingVerification)
+ ]
+ )
+ let probe = RadrootsAppleBackgroundTransferProbe()
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+
+ try await transfer.cancel(request.identifier)
+ try await transfer.expire(request.identifier)
+
+ #expect(try await store.loadSnapshots().first?.state == .awaitingVerification)
+ #expect(await probe.cancelledIdentifiers.isEmpty)
+}
+
+@Test func appleBackgroundTransferCoordinatorRejectsTransfersBeyondImmutableBound() async throws {
+ let roots = try appleTransferRoots()
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store,
+ fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ )
+ let request = try appleUploadRequest(
+ identifier: "field.transfer.too-large", maximumTransferBytes: 5
+ )
+ try await store.saveSnapshot(
+ RadrootsBackgroundTransferSnapshot(request: request, state: .running)
+ )
+
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(
+ platformError: nil,
+ stagedDownloadResult: nil,
+ httpResult: successfulHTTPResult(),
+ bytesTransferred: 6,
+ totalBytesExpected: 6
+ )
+ )
+
+ let snapshot = try #require(try await store.loadSnapshots().first)
+ #expect(snapshot.state == .failed)
+ #expect(snapshot.failure == .transferTooLarge)
+ #expect(snapshot.possibleRemoteOrphan)
+}
+
+@Test func appleBackgroundURLTaskDescriptorRetainsRelaunchBoundsAndMigratesLegacyIdentity() throws {
+ let request = try appleUploadRequest(
+ identifier: "field.transfer.task-descriptor",
+ responsePolicy: .boundedJSON(maximumBodyBytes: 1024),
+ maximumTransferBytes: 4096
+ )
+
+ let descriptor = RadrootsBackgroundURLTaskDescriptor(request: request)
+ #expect(
+ RadrootsBackgroundURLTaskDescriptor(taskDescription: descriptor.taskDescription) == descriptor
+ )
+
+ let legacy = try #require(
+ RadrootsBackgroundURLTaskDescriptor(taskDescription: request.identifier.rawValue)
+ )
+ #expect(legacy.identifier == request.identifier)
+ #expect(
+ legacy.maximumTransferBytes == RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes
+ )
+ #expect(legacy.maximumResponseBodyBytes == 65536)
+ #expect(
+ RadrootsBackgroundURLTaskDescriptor(taskDescription: "radroots-transfer-v1|unsafe|0|0") == nil
+ )
+}
+
+#if os(iOS) && targetEnvironment(simulator)
+ @Test func appleBackgroundTransferLiveURLSessionDownloadsIntoRustVerificationBoundary()
+ async throws {
+ guard let origin = ProcessInfo.processInfo.environment["RADROOTS_URLSESSION_TEST_ORIGIN"],
+ let remoteURL = URL(string: origin)?.appendingPathComponent("Package.swift")
+ else { return }
+ let roots = try appleTransferRoots()
+ defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) }
+ let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ let transfer = try RadrootsAppleBackgroundTransfer(
+ roots: roots,
+ sessionIdentifier: "org.radroots.tests.background-transfer.\(UUID().uuidString.lowercased())"
+ )
+ let destination = RadrootsFileReference(
+ scope: .cache, relativePath: "live-urlsession/package.swift"
+ )
+ let request = try RadrootsBackgroundTransferRequest(
+ identifier: RadrootsBackgroundTransferIdentifier("field.transfer.live-urlsession"),
+ remoteURL: remoteURL,
+ method: .get,
+ operation: .download(destination: .file(destination)),
+ networkPolicy: .simulatorLoopbackHTTP,
+ maximumTransferBytes: 64 * 1024
+ )
+
+ _ = try await transfer.enqueue(request)
+ let deadline = Date().addingTimeInterval(15)
+ var snapshot = try await transfer.snapshot(for: request.identifier)
+ while snapshot?.state == .queued || snapshot?.state == .running {
+ guard Date() < deadline else {
+ throw RadrootsBackgroundTransferError.transferFailure
+ }
+ try await Task.sleep(nanoseconds: 50_000_000)
+ snapshot = try await transfer.snapshot(for: request.identifier)
+ }
+
+ let completedTransfer = try #require(snapshot)
+ #expect(completedTransfer.state == .awaitingVerification)
+ let downloadedArtifact = try #require(completedTransfer.downloadedArtifact)
+ let destinationURL = try resolver.resolve(.file(destination))
+ let downloadedBytes = try Data(contentsOf: destinationURL)
+ #expect(!downloadedBytes.isEmpty)
+ #expect(downloadedArtifact.byteSize == UInt64(downloadedBytes.count))
+ #expect(try downloadedArtifact.sha256 == (RadrootsAppleFileDigest.sha256(at: destinationURL)))
+ #expect(downloadedArtifact.mediaType == "application/octet-stream")
+
+ try await transfer.settle(request.identifier, verification: .accepted)
+ #expect(try await transfer.snapshot(for: request.identifier)?.state == .completed)
+ }
+#endif
+
+@Test func appleBackgroundTransferCancelsRecoveredPlatformOrphans() async throws {
+ let identifier = try RadrootsBackgroundTransferIdentifier("field.transfer.platform-orphan")
+ let probe = RadrootsAppleBackgroundTransferProbe(activeIdentifiers: [identifier])
+ let transfer = RadrootsAppleBackgroundTransfer(
+ store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters()
+ )
+
+ #expect(try await transfer.snapshots().isEmpty)
+ #expect(await probe.cancelledIdentifiers == [identifier])
+}
diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferTestSupport.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferTestSupport.swift
@@ -0,0 +1,169 @@
+import Darwin
+import Foundation
+@testable import RadrootsKit
+import RadrootsKitTesting
+import Testing
+
+actor RadrootsAppleBackgroundTransferProbe {
+ private let nowValue: Date
+ private let enqueueOutcome: Result<Void, RadrootsBackgroundTransferError>
+ private var activeIdentifiersValue: Set<RadrootsBackgroundTransferIdentifier>
+ private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest]
+ private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier]
+ private var handledBackgroundEventIdentifiersValue: [String]
+
+ init(
+ now: Date = Date(timeIntervalSince1970: 0),
+ enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()),
+ activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> = []
+ ) {
+ nowValue = now
+ self.enqueueOutcome = enqueueOutcome
+ activeIdentifiersValue = activeIdentifiers
+ enqueuedRequestsValue = []
+ cancelledIdentifiersValue = []
+ handledBackgroundEventIdentifiersValue = []
+ }
+
+ nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters {
+ RadrootsAppleBackgroundTransferAdapters(
+ now: { self.nowValue }, enqueue: { request, _ in try await self.enqueue(request) },
+ cancel: { identifier in await self.cancel(identifier) },
+ activeTransferIdentifiers: { await self.activeIdentifiers() },
+ handleBackgroundEvents: { identifier, completionHandler in
+ await self.handleBackgroundEvents(
+ identifier: identifier, completionHandler: completionHandler
+ )
+ }
+ )
+ }
+
+ private func enqueue(_ request: RadrootsBackgroundTransferRequest) throws {
+ enqueuedRequestsValue.append(request)
+ switch enqueueOutcome {
+ case .success: activeIdentifiersValue.insert(request.identifier)
+ case let .failure(error): throw error
+ }
+ }
+
+ private func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) {
+ cancelledIdentifiersValue.append(identifier)
+ activeIdentifiersValue.remove(identifier)
+ }
+
+ private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> {
+ activeIdentifiersValue
+ }
+
+ private func handleBackgroundEvents(
+ identifier: String, completionHandler: @escaping @Sendable () -> Void
+ ) {
+ handledBackgroundEventIdentifiersValue.append(identifier)
+ completionHandler()
+ }
+
+ var enqueuedRequests: [RadrootsBackgroundTransferRequest] {
+ enqueuedRequestsValue
+ }
+
+ var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] {
+ cancelledIdentifiersValue
+ }
+
+ var handledBackgroundEventIdentifiers: [String] {
+ handledBackgroundEventIdentifiersValue
+ }
+}
+
+final class RadrootsCompletionProbe: @unchecked Sendable {
+ private let lock = NSLock()
+ private var completionCountValue = 0
+
+ func markCompleted() {
+ lock.lock()
+ completionCountValue += 1
+ lock.unlock()
+ }
+
+ var completed: Bool {
+ completionCount > 0
+ }
+
+ var completionCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return completionCountValue
+ }
+}
+
+final class RadrootsProtectedDataProbe: @unchecked Sendable {
+ private let lock = NSLock()
+ private var stateValue: RadrootsProtectedDataState
+
+ init(state: RadrootsProtectedDataState) {
+ stateValue = state
+ }
+
+ var state: RadrootsProtectedDataState {
+ get {
+ lock.lock()
+ defer { lock.unlock() }
+ return stateValue
+ }
+ set {
+ lock.lock()
+ stateValue = newValue
+ lock.unlock()
+ }
+ }
+}
+
+func appleTransferRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest {
+ try RadrootsBackgroundTransferRequest(
+ identifier: RadrootsBackgroundTransferIdentifier(identifier),
+ remoteURL: URL(string: "https://radroots.org/\(identifier).json")!,
+ method: .get,
+ operation: .download(
+ destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))
+ )
+ )
+}
+
+func appleUploadRequest(
+ identifier: String,
+ responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard,
+ maximumTransferBytes: UInt64 = RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes
+) throws
+ -> RadrootsBackgroundTransferRequest {
+ try RadrootsBackgroundTransferRequest(
+ identifier: RadrootsBackgroundTransferIdentifier(identifier),
+ remoteURL: URL(string: "https://radroots.org/\(identifier).json")!,
+ method: .put,
+ operation: .upload(
+ source: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))
+ ),
+ responsePolicy: responsePolicy, maximumTransferBytes: maximumTransferBytes
+ )
+}
+
+func successfulHTTPResult() -> RadrootsBackgroundHTTPResult {
+ RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false)
+}
+
+func appleTransferRoots() throws -> RadrootsAppleFileRoots {
+ let unresolvedRoot = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "radroots-apple-background-transfer-\(UUID().uuidString)", isDirectory: true
+ )
+ try FileManager.default.createDirectory(at: unresolvedRoot, withIntermediateDirectories: false)
+ guard let resolvedPointer = unresolvedRoot.path.withCString({ Darwin.realpath($0, nil) }) else {
+ throw RadrootsBackgroundTransferError.persistenceFailure
+ }
+ defer { Darwin.free(resolvedPointer) }
+ let root = URL(fileURLWithPath: String(cString: resolvedPointer), isDirectory: true)
+ return try RadrootsAppleFileRoots(
+ appIdentifier: "org.radroots.tests",
+ dataRoot: root.appendingPathComponent("data", isDirectory: true),
+ cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
+ temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)
+ )
+}
diff --git a/Tests/RadrootsKitTests/RadrootsNativeAdmissionTests.swift b/Tests/RadrootsKitTests/RadrootsNativeAdmissionTests.swift
@@ -0,0 +1,284 @@
+import Darwin
+import Foundation
+@testable import RadrootsKit
+import RadrootsKitTesting
+import Testing
+
+@Test func nativeAdmissionReservesBeforeAwaitAndPreservesQueuedOwnership() async throws {
+ let fixture = try NativeAdmissionFixture()
+ defer { fixture.remove() }
+ let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots)
+ let gate = NativeAdmissionGate()
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: gate.adapters())
+ let request = try nativeAdmissionRequest()
+ let first = Task { try await transfer.enqueue(request) }
+ await gate.waitUntilEntered()
+ await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) {
+ _ = try await transfer.enqueue(request)
+ }
+ let conflicting = try nativeAdmissionRequest(remote: "https://example.org/other")
+ await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) {
+ _ = try await transfer.enqueue(conflicting)
+ }
+ let secondStore = RadrootsAppleBackgroundTransferStore(roots: fixture.roots)
+ let secondOwner = RadrootsAppleBackgroundTransfer(store: secondStore, adapters: gate.adapters())
+ #expect(try await secondOwner.snapshots().first?.state == .queued)
+ await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) {
+ _ = try await secondOwner.enqueue(request)
+ }
+ let queued = try #require(try await transfer.snapshots().first)
+ #expect(queued.state == .queued)
+ #expect(queued.request.headers.isEmpty && queued.request.metadata.isEmpty)
+ #expect(queued.executionID != nil)
+ await gate.finish()
+ #expect(try await first.value.identifier == request.identifier)
+ #expect(await gate.admittedCount == 1)
+ #expect(try await !secondStore.admissionIsActive(for: request.identifier))
+ #expect(try await transfer.snapshot(for: request.identifier)?.state == .running)
+}
+
+@Test func nativeAdmissionDifferentIdentifiersCanProceedWhileAnotherAwaitIsHeld() async throws {
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let firstGate = NativeAdmissionGate()
+ let secondGate = NativeAdmissionGate()
+ let first = RadrootsAppleBackgroundTransfer(store: store, adapters: firstGate.adapters())
+ let second = RadrootsAppleBackgroundTransfer(store: store, adapters: secondGate.adapters())
+ let original = try nativeAdmissionRequest()
+ let other = try RadrootsBackgroundTransferRequest(
+ identifier: RadrootsBackgroundTransferIdentifier("native.admission.other"),
+ remoteURL: original.remoteURL, method: original.method, operation: original.operation
+ )
+ let firstTask = Task { try await first.enqueue(original) }
+ await firstGate.waitUntilEntered()
+ let secondTask = Task { try await second.enqueue(other) }
+ await secondGate.waitUntilEntered()
+ #expect(try await store.loadSnapshots().count == 2)
+ await secondGate.finish()
+ #expect(try await secondTask.value.identifier == other.identifier)
+ await firstGate.finish()
+ #expect(try await firstTask.value.identifier == original.identifier)
+ #expect(await firstGate.admittedCount == 1)
+ #expect(await secondGate.admittedCount == 1)
+}
+
+@Test func nativeAdmissionCancellationWhileEnqueueSuspendsCancelsLateTask() async throws {
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let gate = NativeAdmissionGate()
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: gate.adapters())
+ let request = try nativeAdmissionRequest()
+ let pending = Task { try await transfer.enqueue(request) }
+ await gate.waitUntilEntered()
+ try await transfer.cancel(request.identifier)
+ await gate.finish()
+ _ = try await pending.value
+ #expect(await gate.active.isEmpty)
+ let snapshot = try #require(try await store.loadSnapshots().first)
+ #expect(snapshot.state == .cancelled && snapshot.possibleRemoteOrphan)
+}
+
+@Test func nativeAdmissionErrorAfterEffectPreservesUnknownOutcomeAndExistingReceipt() async throws {
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let request = try nativeAdmissionRequest()
+ let adapters = RadrootsAppleBackgroundTransferAdapters(
+ enqueue: { _, executionID in
+ let existing = try #require(try await store.loadSnapshots().first)
+ #expect(existing.executionID == executionID)
+ let completed = try RadrootsBackgroundTransferSnapshot(
+ request: existing.request, state: .awaitingVerification,
+ response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil),
+ executionID: executionID
+ )
+ #expect(try await store.compareExchangeSnapshot(expected: existing, desired: completed))
+ throw RadrootsBackgroundTransferError.transferFailure
+ }, cancel: { _ in }, activeTransferIdentifiers: { [] },
+ handleBackgroundEvents: { _, completion in completion() }
+ )
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters)
+ await #expect(throws: RadrootsBackgroundTransferError.transferFailure) { _ = try await transfer.enqueue(request) }
+ #expect(try await store.loadSnapshots().first?.state == .awaitingVerification)
+ let lost = try RadrootsBackgroundTransferSnapshot(request: request, state: .queued, executionID: UUID())
+ let recoveredStore = RadrootsInMemoryBackgroundTransferStore(snapshots: [lost])
+ let recovered = RadrootsAppleBackgroundTransfer(store: recoveredStore, adapters: adapters)
+ let interrupted = try #require(try await recovered.snapshots().first)
+ #expect(interrupted.state == .interrupted && interrupted.possibleRemoteOrphan)
+}
+
+@Test func nativeAdmissionStaleAndDuplicateCallbacksCannotOverwriteNewAttempt() async throws {
+ let fixture = try NativeAdmissionFixture()
+ defer { fixture.remove() }
+ let request = try nativeAdmissionRequest()
+ let generation = UUID()
+ let running = try RadrootsBackgroundTransferSnapshot(request: request, state: .running, executionID: generation)
+ let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [running])
+ let coordinator = RadrootsTransferCoordinator(
+ sessionIdentifier: "tests", store: store,
+ fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: fixture.roots)
+ )
+ let response = RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false)
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, httpResult: response,
+ bytesTransferred: 3, totalBytesExpected: 3),
+ executionID: UUID()
+ )
+ #expect(try await store.loadSnapshots().first == running)
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, httpResult: response,
+ bytesTransferred: 3, totalBytesExpected: 3),
+ executionID: generation
+ )
+ let completed = try #require(try await store.loadSnapshots().first)
+ #expect(completed.state == .awaitingVerification && completed.executionID == generation)
+ await coordinator.updateProgress(identifier: request.identifier, bytesTransferred: 1, totalBytesExpected: 3,
+ executionID: generation)
+ await coordinator.complete(
+ identifier: request.identifier,
+ completion: RadrootsTransferCompletion(platformError: RadrootsBackgroundTransferError.transferFailure,
+ stagedDownloadResult: nil, httpResult: response, bytesTransferred: 1,
+ totalBytesExpected: 3),
+ executionID: generation
+ )
+ #expect(try await store.loadSnapshots().first == completed)
+}
+
+@Test func nativeAdmissionPersistsExactLeaseAndGenerationAcrossRestart() async throws {
+ let fixture = try NativeAdmissionFixture()
+ defer { fixture.remove() }
+ let file = RadrootsFileReference(scope: .cache, relativePath: "body")
+ let bytes = Data("exact upload bytes".utf8)
+ let fileOwner = RadrootsAppleFileAccess(roots: fixture.roots)
+ try fileOwner.write(.inline(bytes), to: file)
+ let request = try nativeAdmissionRequest(expectedSHA256: RadrootsAppleFileDigest.sha256(bytes))
+ let generation = UUID()
+ let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: fixture.roots)
+ let lease = try resolver.prepareUploadLease(for: request, executionID: generation, existing: nil)
+ let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots)
+ let queued = try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ executionID: generation,
+ uploadLease: lease.blob
+ )
+ #expect(try await store.compareExchangeSnapshot(expected: nil, desired: queued))
+ try fileOwner.reset(scope: .cache)
+ let reopened = RadrootsAppleBackgroundTransferStore(roots: fixture.roots)
+ let saved = try #require(try await reopened.loadSnapshots().first)
+ #expect(saved.request.headers.isEmpty && saved.request.metadata.isEmpty)
+ #expect(saved.executionID == generation && saved.uploadLease == lease.blob)
+ let recovered = try resolver.prepareUploadLease(
+ for: saved.request,
+ executionID: generation,
+ existing: saved.uploadLease
+ )
+ #expect(recovered == lease)
+ #expect(try Data(contentsOf: recovered.fileURL) == bytes)
+ let descriptor = RadrootsBackgroundURLTaskDescriptor(request: request, executionID: generation)
+ #expect(RadrootsBackgroundURLTaskDescriptor(taskDescription: descriptor.taskDescription) == descriptor)
+ #expect(RadrootsBackgroundURLTaskDescriptor(taskDescription: "radroots-transfer-v2|bad|0|0|invalid") == nil)
+ try resolver.releaseUploadLease(executionID: generation)
+ #expect(!FileManager.default.fileExists(atPath: lease.fileURL.path))
+}
+
+@Test func nativeAdmissionStoreClaimsOnceAndRejectsStaleTransition() async throws {
+ let fixture = try NativeAdmissionFixture()
+ defer { fixture.remove() }
+ let request = try nativeAdmissionRequest()
+ let results = await withTaskGroup(of: Bool.self) { group in
+ for _ in 0 ..< 8 {
+ group.addTask {
+ let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots)
+ let desired = try? RadrootsBackgroundTransferSnapshot(request: request, executionID: UUID())
+ guard let desired else { return false }
+ return await (try? store.compareExchangeSnapshot(expected: nil, desired: desired)) ?? false
+ }
+ }
+ var results: [Bool] = []
+ for await result in group {
+ results.append(result)
+ }
+ return results
+ }
+ #expect(results.filter(\.self).count == 1)
+ let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots)
+ let original = try #require(try await store.loadSnapshots().first)
+ let cancelled = try original.transitioned(to: .cancelled, at: Date(), possibleRemoteOrphan: true)
+ #expect(try await store.compareExchangeSnapshot(expected: original, desired: cancelled))
+ #expect(try await !store.compareExchangeSnapshot(expected: original,
+ desired: original.transitioned(to: .running, at: Date())))
+ #expect(try await store.loadSnapshots().first == cancelled)
+}
+
+private actor NativeAdmissionGate {
+ private var entered = false
+ private var observer: CheckedContinuation<Void, Never>?
+ private var pending: CheckedContinuation<Void, Never>?
+ private(set) var active: Set<RadrootsBackgroundTransferIdentifier> = []
+ private(set) var admittedCount = 0
+
+ nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters {
+ RadrootsAppleBackgroundTransferAdapters(enqueue: { request, _ in await self.enqueue(request) },
+ cancel: { identifier in await self.cancel(identifier) },
+ activeTransferIdentifiers: { await self.active },
+ handleBackgroundEvents: { _, completion in completion() })
+ }
+
+ func waitUntilEntered() async {
+ if entered {
+ return
+ }
+ await withCheckedContinuation { observer = $0 }
+ }
+
+ func finish() {
+ pending?.resume(); pending = nil
+ }
+
+ func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) {
+ active.remove(identifier)
+ }
+
+ private func enqueue(_ request: RadrootsBackgroundTransferRequest) async {
+ admittedCount += 1
+ entered = true
+ observer?.resume()
+ observer = nil
+ await withCheckedContinuation { pending = $0 }
+ active.insert(request.identifier)
+ }
+}
+
+private func nativeAdmissionRequest(
+ remote: String = "https://example.org/upload", expectedSHA256: String? = nil
+) throws -> RadrootsBackgroundTransferRequest {
+ try RadrootsBackgroundTransferRequest(identifier: RadrootsBackgroundTransferIdentifier("native.admission"),
+ remoteURL: #require(URL(string: remote)), method: .put,
+ operation: .upload(source: .file(RadrootsFileReference(
+ scope: .cache,
+ relativePath: "body"
+ ))),
+ headers: ["Authorization": "test-only-authority"],
+ metadata: ["label": "test-only-private-metadata"],
+ expectedSourceSHA256: expectedSHA256)
+}
+
+private struct NativeAdmissionFixture {
+ let base: URL
+ let roots: RadrootsAppleFileRoots
+ init() throws {
+ let raw = FileManager.default.temporaryDirectory.appendingPathComponent("native-admission-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: raw, withIntermediateDirectories: true)
+ let pointer = try #require(raw.path.withCString { Darwin.realpath($0, nil) })
+ defer { Darwin.free(pointer) }
+ base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true)
+ roots = try RadrootsAppleFileRoots(
+ appIdentifier: "org.radroots.tests",
+ dataRoot: base.appendingPathComponent("data"),
+ cacheRoot: base.appendingPathComponent("cache"),
+ temporaryRoot: base.appendingPathComponent("tmp")
+ )
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: base)
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsNativeUploadLeaseTests.swift b/Tests/RadrootsKitTests/RadrootsNativeUploadLeaseTests.swift
@@ -0,0 +1,151 @@
+#if os(iOS) && targetEnvironment(simulator)
+ import Foundation
+ import Network
+ @testable import RadrootsKit
+ import RadrootsKitTesting
+ import Testing
+
+ @Test func nativeUploadConsumesLeaseAfterOriginalChangesBeforeTaskCreation() async throws {
+ let server = try NativeLeaseHTTPServer()
+ defer { server.stop() }
+ let port = try await server.start()
+ let roots = try appleTransferRoots()
+ defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) }
+ let bytes = Data("immutable native upload body".utf8)
+ let source = RadrootsFileReference(scope: .cache, relativePath: "native_upload")
+ try RadrootsAppleFileAccess(roots: roots).write(.inline(bytes), to: source)
+ let resolver = ReplacingNativeLeaseResolver(roots: roots, source: source)
+ let store = RadrootsAppleBackgroundTransferStore(roots: roots)
+ let adapters = try RadrootsAppleBackgroundTransferAdapters.live(
+ sessionIdentifier: "org.radroots.native-lease.\(UUID().uuidString.lowercased())",
+ store: store, fileResolver: resolver, downloadStagingRoot: roots.temporaryRoot
+ )
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters)
+ let request = try RadrootsBackgroundTransferRequest(
+ identifier: RadrootsBackgroundTransferIdentifier("native.lease.upload"),
+ remoteURL: #require(URL(string: "http://127.0.0.1:\(port)/upload")), method: .put,
+ operation: .upload(source: .file(source)), networkPolicy: .simulatorLoopbackHTTP,
+ expectedSourceSHA256: RadrootsAppleFileDigest.sha256(bytes), maximumTransferBytes: 65536
+ )
+ _ = try await transfer.enqueue(request)
+ await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) { _ = try await transfer.enqueue(request)
+ }
+ let deadline = Date().addingTimeInterval(15)
+ var snapshot = try await transfer.snapshot(for: request.identifier)
+ while snapshot?.state == .queued || snapshot?.state == .running {
+ guard Date() < deadline else { throw RadrootsBackgroundTransferError.transferFailure }
+ try await Task.sleep(for: .milliseconds(25))
+ snapshot = try await transfer.snapshot(for: request.identifier)
+ }
+ let completed = try #require(snapshot)
+ #expect(completed.state == .awaitingVerification)
+ #expect(completed.uploadLease?.blobID == request.expectedSourceSHA256)
+ #expect(server.bodies == [bytes])
+ #expect(try resolver.read(.file(source), maximumBytes: 65536) != bytes)
+ try await transfer.settle(request.identifier, verification: .accepted)
+ #expect(try await transfer.snapshot(for: request.identifier)?.state == .completed)
+ }
+
+ private struct ReplacingNativeLeaseResolver: RadrootsBackgroundTransferFileResolver {
+ let roots: RadrootsAppleFileRoots
+ let source: RadrootsFileReference
+ private var live: RadrootsAppleBackgroundTransferFileResolver {
+ .init(roots: roots)
+ }
+
+ func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL {
+ try live.resolve(file)
+ }
+
+ func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data {
+ try live.read(file, maximumBytes: maximumBytes)
+ }
+
+ func prepareUploadLease(
+ for request: RadrootsBackgroundTransferRequest, executionID: UUID, existing: RadrootsStagedBlobReference?
+ ) throws -> RadrootsStagedBlobLease {
+ let lease = try live.prepareUploadLease(for: request, executionID: executionID, existing: existing)
+ try RadrootsAppleFileAccess(roots: roots).write(.inline(Data("replaced original".utf8)), to: source)
+ return lease
+ }
+
+ func releaseUploadLease(executionID: UUID) throws {
+ try live.releaseUploadLease(executionID: executionID)
+ }
+ }
+
+ /// Network callbacks use one private queue; observed result copies use a lock.
+ private final class NativeLeaseHTTPServer: @unchecked Sendable {
+ private let listener: NWListener
+ private let queue = DispatchQueue(label: "org.radroots.tests.native-lease-http")
+ private let lock = NSLock()
+ private var receivedBodies: [Data] = []
+ private var connections: [NWConnection] = []
+
+ init() throws {
+ let parameters = NWParameters.tcp
+ parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
+ listener = try NWListener(using: parameters)
+ }
+
+ var bodies: [Data] {
+ lock.withLock { receivedBodies }
+ }
+
+ func start() async throws -> UInt16 {
+ listener.newConnectionHandler = { [weak self] connection in
+ guard let self else { connection.cancel(); return }
+ connections.append(connection)
+ connection.start(queue: queue)
+ read(connection, accumulated: Data())
+ }
+ listener.start(queue: queue)
+ for _ in 0 ..< 500 {
+ if let port = listener.port, port.rawValue > 0 {
+ return port.rawValue
+ }
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ throw RadrootsBackgroundTransferError.unavailable
+ }
+
+ func stop() {
+ listener.cancel()
+ queue.async { for connection in self.connections {
+ connection.cancel()
+ } }
+ }
+
+ private func read(_ connection: NWConnection, accumulated: Data) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, complete, error in
+ var bytes = accumulated
+ if let data {
+ bytes.append(data)
+ }
+ guard error == nil, bytes.count <= 131_072 else { connection.cancel(); return }
+ if let body = self.body(in: bytes) {
+ self.lock.withLock { self.receivedBodies.append(body) }
+ let response = Data("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8)
+ connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() })
+ } else if complete {
+ connection.cancel()
+ } else {
+ self.read(connection, accumulated: bytes)
+ }
+ }
+ }
+
+ private func body(in bytes: Data) -> Data? {
+ guard let separator = bytes.range(of: Data("\r\n\r\n".utf8)),
+ let header = String(data: bytes[..<separator.lowerBound], encoding: .utf8),
+ header.hasPrefix("PUT /upload HTTP/1.1\r\n")
+ else { return nil }
+ let length = header.components(separatedBy: "\r\n").first {
+ $0.lowercased().hasPrefix("content-length:")
+ }.flatMap { Int($0.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)) }
+ guard let length, (0 ... 65536).contains(length),
+ bytes.count - separator.upperBound == length else { return nil }
+ return Data(bytes[separator.upperBound...])
+ }
+ }
+#endif
diff --git a/contracts/api_baselines/apple_kit.txt b/contracts/api_baselines/apple_kit.txt
@@ -715,13 +715,17 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 10:conformsTo 56:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV 22:s:s16SendableMetatypeP 22:Swift.SendableMetatype
12:relationship 11:RadrootsKit 13:requirementOf 105:s:11RadrootsKit0A10FileAccessP16sweepStagedBlobs9olderThanSayAA0aF13BlobReferenceVG10Foundation4DateV_tKF 30:s:11RadrootsKit0A10FileAccessP 0:
12:relationship 11:RadrootsKit 13:requirementOf 106:s:11RadrootsKit0A11SecureStoreP3put_3for6policyy10Foundation4DataV_AA0acD3KeyVAA0A18SecretAccessPolicyVtKF 31:s:11RadrootsKit0A11SecureStoreP 0:
+12:relationship 11:RadrootsKit 13:requirementOf 106:s:11RadrootsKit0A23BackgroundTransferStoreP23compareExchangeSnapshot8expected7desiredSbAA0acdH0VSg_AHtYaKF 43:s:11RadrootsKit0A23BackgroundTransferStoreP 0:
+12:relationship 11:RadrootsKit 13:requirementOf 106:s:11RadrootsKit0A30BackgroundTransferFileResolverP18releaseUploadLease11executionIDy10Foundation4UUIDV_tKF 50:s:11RadrootsKit0A30BackgroundTransferFileResolverP 0:
12:relationship 11:RadrootsKit 13:requirementOf 107:s:11RadrootsKit0A18BackgroundTransferP6settle_12verificationyAA0acD10IdentifierV_AA0acD12VerificationOtYaKF 38:s:11RadrootsKit0A18BackgroundTransferP 0:
12:relationship 11:RadrootsKit 13:requirementOf 108:s:11RadrootsKit0A10FileAccessP05stageC0_9mediaType12filenameHintAA0A19StagedBlobReferenceVAA0acL0V_SSSgAKtKF 30:s:11RadrootsKit0A10FileAccessP 0:
12:relationship 11:RadrootsKit 13:requirementOf 109:s:11RadrootsKit0A30BackgroundTransferFileResolverP4read_12maximumBytes10Foundation4DataVAA0acd5LocalE0O_SitKF 50:s:11RadrootsKit0A30BackgroundTransferFileResolverP 0:
12:relationship 11:RadrootsKit 13:requirementOf 113:s:11RadrootsKit0A18BackgroundTransferP015handleEventsForC10URLSession10identifier17completionHandlerySS_yyYbctYaF 38:s:11RadrootsKit0A18BackgroundTransferP 0:
12:relationship 11:RadrootsKit 13:requirementOf 116:s:11RadrootsKit0A10FileAccessP9stageBlob_9mediaType12filenameHintAA0a6StagedF9ReferenceV10Foundation4DataV_SSSgALtKF 30:s:11RadrootsKit0A10FileAccessP 0:
+12:relationship 11:RadrootsKit 13:requirementOf 120:s:11RadrootsKit0A23BackgroundTransferStoreP13withAdmission3for9operationAA0acD6HandleVAA0acD10IdentifierV_AHyYaYbKctYaKF 43:s:11RadrootsKit0A23BackgroundTransferStoreP 0:
12:relationship 11:RadrootsKit 13:requirementOf 126:s:11RadrootsKit0A10FileAccessP013stageExternalC0_9mediaType12filenameHintAA0A19StagedBlobReferenceV10Foundation3URLV_SSSgALtKF 30:s:11RadrootsKit0A10FileAccessP 0:
12:relationship 11:RadrootsKit 13:requirementOf 146:s:11RadrootsKit0A10FileAccessP012copyExternalC0_2to9mediaType17suggestedFilenameAA0A16ImportedDocumentV10Foundation3URLV_AA0aC9ReferenceVSSSgAOtKF 30:s:11RadrootsKit0A10FileAccessP 0:
+12:relationship 11:RadrootsKit 13:requirementOf 171:s:11RadrootsKit0A30BackgroundTransferFileResolverP18prepareUploadLease3for11executionID8existingAA0a10StagedBlobI0VAA0acD7RequestV_10Foundation4UUIDVAA0anO9ReferenceVSgtKF 50:s:11RadrootsKit0A30BackgroundTransferFileResolverP 0:
12:relationship 11:RadrootsKit 13:requirementOf 52:s:11RadrootsKit0A10FileAccessP16resetStagedBlobsyyKF 30:s:11RadrootsKit0A10FileAccessP 0:
12:relationship 11:RadrootsKit 13:requirementOf 52:s:11RadrootsKit0A9TelemetryP6recordyyAA0aC5EventVYaF 28:s:11RadrootsKit0A9TelemetryP 0:
12:relationship 11:RadrootsKit 13:requirementOf 53:s:11RadrootsKit0A11SecureStoreP6deleteyyAA0acD3KeyVKF 31:s:11RadrootsKit0A11SecureStoreP 0:
@@ -767,6 +771,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 13:requirementOf 89:s:11RadrootsKit0A24PermissionStatusProviderP8snapshot3forAA0aC8SnapshotVAA0aC4KindO_tYaKF 44:s:11RadrootsKit0A24PermissionStatusProviderP 0:
12:relationship 11:RadrootsKit 13:requirementOf 91:s:11RadrootsKit0A10FileAccessP4read_4modeAA0aC10ReadResultOAA0aC9ReferenceV_AA0acG4ModeOtKF 30:s:11RadrootsKit0A10FileAccessP 0:
12:relationship 11:RadrootsKit 13:requirementOf 91:s:11RadrootsKit0A15ExternalActionsP7canOpenyAA0aC16ActionCapabilityVAA0acG11DestinationVYaF 35:s:11RadrootsKit0A15ExternalActionsP 0:
+12:relationship 11:RadrootsKit 13:requirementOf 93:s:11RadrootsKit0A23BackgroundTransferStoreP17admissionIsActive3forSbAA0acD10IdentifierV_tYaKF 43:s:11RadrootsKit0A23BackgroundTransferStoreP 0:
12:relationship 11:RadrootsKit 13:requirementOf 93:s:11RadrootsKit0A30BackgroundTransferFileResolverP7resolvey10Foundation3URLVAA0acd5LocalE0OKF 50:s:11RadrootsKit0A30BackgroundTransferFileResolverP 0:
12:relationship 11:RadrootsKit 13:requirementOf 94:s:11RadrootsKit0A18BackgroundTransferP8snapshot3forAA0acD8SnapshotVSgAA0acD10IdentifierV_tYaKF 38:s:11RadrootsKit0A18BackgroundTransferP 0:
12:relationship 11:RadrootsKit 13:requirementOf 98:s:11RadrootsKit0A24PermissionStatusProviderP9snapshots3forSayAA0aC8SnapshotVGSayAA0aC4KindOG_tYaKF 44:s:11RadrootsKit0A24PermissionStatusProviderP 0:
@@ -785,6 +790,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 103:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV010permissionE03forAA0adE0OSo015UNAuthorizationE0V_tFZ 49:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 104:s:11RadrootsKit0A23CaptureIntakeValidationO28normalizedScannerOutputKindsySayAA0a8DocumentgH4KindOGAGKFZ 43:s:11RadrootsKit0A23CaptureIntakeValidationO 0:
12:relationship 11:RadrootsKit 8:memberOf 104:s:11RadrootsKit0A26BackgroundTransferProgressV16bytesTransferred18totalBytesExpectedACs5Int64V_AGSgtKcfc 46:s:11RadrootsKit0A26BackgroundTransferProgressV 0:
+12:relationship 11:RadrootsKit 8:memberOf 104:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV7enqueueyyAA0adE7RequestV_10Foundation4UUIDVtYaYbKcvp 51:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A15AppleFileAccessC17installStagedBlob_9referencey10Foundation4DataV_AA0agH9ReferenceVtKF 35:s:11RadrootsKit0A15AppleFileAccessC 0:
12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A26AppleIdentityMetadataStoreC9namespace12userDefaults9keyPrefixACSS_So06NSUserI0CSStKcfc 46:s:11RadrootsKit0A26AppleIdentityMetadataStoreC 0:
12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A29UnavailableBackgroundTransferV8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 49:s:11RadrootsKit0A29UnavailableBackgroundTransferV 0:
@@ -804,6 +810,8 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 110:s:11RadrootsKit0A19DocumentScanRequestV10outputKind16destinationScopeAcA0ac13ScannerOutputG0O_AA0a4FileI0Otcfc 39:s:11RadrootsKit0A19DocumentScanRequestV 0:
12:relationship 11:RadrootsKit 8:memberOf 110:s:11RadrootsKit0A23AppleTelemetryLogRecordV9subsystem8category5level15renderedMessageACSS_SSAA0aD5LevelOSStcfc 43:s:11RadrootsKit0A23AppleTelemetryLogRecordV 0:
12:relationship 11:RadrootsKit 8:memberOf 111:s:11RadrootsKit0A27DocumentPresentationAdapterO11contentType08forMediaG007UniformG11Identifiers6UTTypeVSSSg_tFZ 47:s:11RadrootsKit0A27DocumentPresentationAdapterO 0:
+12:relationship 11:RadrootsKit 8:memberOf 111:s:11RadrootsKit0A28AppleBackgroundTransferStoreC23compareExchangeSnapshot8expected7desiredSbAA0adeI0VSg_AHtYaKF 48:s:11RadrootsKit0A28AppleBackgroundTransferStoreC 0:
+12:relationship 11:RadrootsKit 8:memberOf 111:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV18releaseUploadLease11executionIDy10Foundation4UUIDV_tKF 55:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV 0:
12:relationship 11:RadrootsKit 8:memberOf 112:s:11RadrootsKit0A18AppLocalStateResetO23clearApplicationSupport13appIdentifier11fileManagerySS_So06NSFileM0CtKFZ 38:s:11RadrootsKit0A18AppLocalStateResetO 0:
12:relationship 11:RadrootsKit 8:memberOf 112:s:11RadrootsKit0A23AppleBackgroundTransferC6settle_12verificationyAA0adE10IdentifierV_AA0adE12VerificationOtYaKF 43:s:11RadrootsKit0A23AppleBackgroundTransferC 0:
12:relationship 11:RadrootsKit 8:memberOf 112:s:11RadrootsKit0A28LocationServicesAvailabilityV08locationD7Enabled13authorizationACSb_AA0aC13AuthorizationOtcfc 48:s:11RadrootsKit0A28LocationServicesAvailabilityV 0:
@@ -832,6 +840,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 125:s:11RadrootsKit0A18MediaImportRequestV07allowedC5Kinds14selectionLimit16destinationScopeACSayAA0aC4KindOG_SiAA0a4FileK0OtKcfc 38:s:11RadrootsKit0A18MediaImportRequestV 0:
12:relationship 11:RadrootsKit 8:memberOf 125:s:11RadrootsKit0A21CurrentLocationResultV7reading13authorization15servedFromCacheAcA0aD7ReadingV_AA0aD13AuthorizationOSbtKcfc 41:s:11RadrootsKit0A21CurrentLocationResultV 0:
12:relationship 11:RadrootsKit 8:memberOf 125:s:11RadrootsKit0A22CurrentLocationRequestV14timeoutSeconds21desiredAccuracyMeters023maximumCachedReadingAgeG0ACSd_SdSgAGtKcfc 42:s:11RadrootsKit0A22CurrentLocationRequestV 0:
+12:relationship 11:RadrootsKit 8:memberOf 125:s:11RadrootsKit0A28AppleBackgroundTransferStoreC13withAdmission3for9operationAA0adE6HandleVAA0adE10IdentifierV_AHyYaYbKctYaKF 48:s:11RadrootsKit0A28AppleBackgroundTransferStoreC 0:
12:relationship 11:RadrootsKit 8:memberOf 126:s:11RadrootsKit0A24ExternalActionValidationO12appleMapsURL10coordinate5label10Foundation0H0VAA0A18LocationCoordinateV_SSSgtKFZ 44:s:11RadrootsKit0A24ExternalActionValidationO 0:
12:relationship 11:RadrootsKit 8:memberOf 126:s:11RadrootsKit0A26BackgroundTransferResponseV10statusCode9mediaType15contentEncoding4bodyACSi_SSSgAH10Foundation4DataVSgtKcfc 46:s:11RadrootsKit0A26BackgroundTransferResponseV 0:
12:relationship 11:RadrootsKit 8:memberOf 126:s:11RadrootsKit0A27DocumentPresentationAdapterO12contentTypes3forSay22UniformTypeIdentifiers6UTTypeVGAA0aC13ImportRequestV_tFZ 47:s:11RadrootsKit0A27DocumentPresentationAdapterO 0:
@@ -865,6 +874,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 174:s:11RadrootsKit0A17OpaqueSignRequestV11operationID12signerHandle12publicKeyHex6digest7purpose24deadlineUnixMillisecondsACSS_S2S10Foundation4DataVAA0acD7PurposeOs6UInt64VtKcfc 37:s:11RadrootsKit0A17OpaqueSignRequestV 0:
12:relationship 11:RadrootsKit 8:memberOf 175:s:11RadrootsKit0A34DocumentImportPresentationModifierV7request10fileAccess12onCompletionAC7SwiftUI7BindingVyAA0acD7RequestVSgG_AA0a4FileI0_pys6ResultOyAA0acdQ0Vs5Error_pGctcfc 54:s:11RadrootsKit0A34DocumentImportPresentationModifierV 0:
12:relationship 11:RadrootsKit 8:memberOf 175:s:7SwiftUI4ViewP11RadrootsKitE24radrootsDocumentImporter7request10fileAccess12onCompletionQrAA7BindingVyAD0dG13ImportRequestVSgG_AD0d4FileK0_pys6ResultOyAD0dgoR0Vs5Error_pGctF 16:s:7SwiftUI4ViewP 16:SwiftUICore.View
+12:relationship 11:RadrootsKit 8:memberOf 176:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV18prepareUploadLease3for11executionID8existingAA0a10StagedBlobJ0VAA0adE7RequestV_10Foundation4UUIDVAA0aoP9ReferenceVSgtKF 55:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV 0:
12:relationship 11:RadrootsKit 8:memberOf 182:s:11RadrootsKit0A16AppleMobileStoreO7prepare5roots12publicKeyHex25protectedDataAvailability11fileManagerAA0acdE13ConfigurationVAA0aC9FileRootsV_SSAA0ac9ProtectedlM0OSo06NSFileO0CtKFZ 36:s:11RadrootsKit0A16AppleMobileStoreO 0:
12:relationship 11:RadrootsKit 8:memberOf 182:s:11RadrootsKit0A21BackgroundTaskRequestV10identifier4kind17earliestBeginDate27requiresNetworkConnectivity0K13ExternalPowerAcA0acD10IdentifierV_AA0acD4KindO10Foundation0J0VSgS2btKcfc 41:s:11RadrootsKit0A21BackgroundTaskRequestV 0:
12:relationship 11:RadrootsKit 8:memberOf 184:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV3now012notificationE006cameraE006photosE0010microphoneE008locationE0AC10Foundation4DateVyYbc_AA0adE0OyYaYbcANyYbcANyYbcANyYbcANyYbctcfc 49:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV 0:
@@ -873,14 +883,14 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 198:s:11RadrootsKit0A22BackgroundTaskSnapshotV10identifier4kind17earliestBeginDate11submittedAt27requiresNetworkConnectivity0M13ExternalPowerAcA0acD10IdentifierV_AA0acD4KindO10Foundation0J0VSgAPS2btKcfc 42:s:11RadrootsKit0A22BackgroundTaskSnapshotV 0:
12:relationship 11:RadrootsKit 8:memberOf 202:s:11RadrootsKit0A15ScannedDocumentV4file10outputKind17suggestedFilename9mediaType9pageCount9sizeBytes10capturedAtAcA0A13FileReferenceV_AA0ad13ScannerOutputG0OS2Ss6UInt16Vs6UInt64V10Foundation4DateVtKcfc 35:s:11RadrootsKit0A15ScannedDocumentV 0:
12:relationship 11:RadrootsKit 8:memberOf 213:s:11RadrootsKit0A10MediaAssetV6source4kind4file9mediaType17suggestedFilename9sizeBytes10pixelWidth0N6Height10capturedAtAcA0aC6SourceO_AA0aC4KindOAA0A13FileReferenceVS2Ss6UInt64Vs6UInt32VSgAW10Foundation4DateVtKcfc 30:s:11RadrootsKit0A10MediaAssetV 0:
-12:relationship 11:RadrootsKit 8:memberOf 213:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV3now7enqueue6cancel06activeE11Identifiers06handleD6EventsAC10Foundation4DateVyYbc_yAA0adE7RequestVYaYbKcyAA0adE10IdentifierVYaYbKcShyAOGyYaYbKcySS_yyYbctYaYbctcfc 51:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 220:s:11RadrootsKit0A15IdentityCustodyC13configuration11secureStore08metadataG012userPresence13protectedData3nowAcA0acD13ConfigurationV_AA0a6SecureG0_pAA0ac8MetadataG0_pAA0a4UserJ0_pAA0a9ProtectedL8ProviderVs6UInt64VyYbctcfc 35:s:11RadrootsKit0A15IdentityCustodyC 0:
+12:relationship 11:RadrootsKit 8:memberOf 223:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV3now7enqueue6cancel06activeE11Identifiers06handleD6EventsAC10Foundation4DateVyYbc_yAA0adE7RequestV_AI4UUIDVtYaYbKcyAA0adE10IdentifierVYaYbKcShyAQGyYaYbKcySS_yyYbctYaYbctcfc 51:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 228:s:11RadrootsKit0A29AppleLocationServicesAdaptersV3now08locationE7Enabled19authorizationStatus29requestWhenInUseAuthorization0l7CurrentD0AC10Foundation4DateVyYbc_SbyYbcAA0adP0OyYbcAMSdYaYbKcAA0aD7ReadingVAA0aqD7RequestVYaYbKctcfc 49:s:11RadrootsKit0A29AppleLocationServicesAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 229:s:11RadrootsKit0A18AppleMediaPreparerC20blossomUploadRequest13preparedImage9remoteURL13authorization13networkPolicy10identifierAA0a18BackgroundTransferH0VAA0ac8PreparedJ0V_10Foundation0L0VSSAA0aqr7NetworkO0OAA0aqR10IdentifierVtKF 38:s:11RadrootsKit0A18AppleMediaPreparerC 0:
12:relationship 11:RadrootsKit 8:memberOf 243:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV3now8register6submit6cancel0K3All12pendingTasksAC10Foundation4DateVyYbc_SbAA0acdE12RegistrationVYaYbKcyAA0adE7RequestVYaYbKcyAA0adE10IdentifierVYaYbcyyYaYbcSayAA0adE8SnapshotVGyYaYbKctcfc 56:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV 0:
-12:relationship 11:RadrootsKit 8:memberOf 266:s:11RadrootsKit0A26BackgroundTransferSnapshotV7request5state8progress7failure8response18downloadedArtifact20possibleRemoteOrphan9updatedAtAcA0acD7RequestV_AA0acD5StateOAA0acD8ProgressVAA0acD7FailureOSgAA0acD8ResponseVSgAA0ac10DownloadedL0VSgSb10Foundation4DateVtKcfc 46:s:11RadrootsKit0A26BackgroundTransferSnapshotV 0:
12:relationship 11:RadrootsKit 8:memberOf 275:s:11RadrootsKit0A28BackgroundTransferValidationO8validate9remoteURL6method9operation7headers8metadata13networkPolicy08responseN020expectedSourceSHA25607maximumD5Bytesy10Foundation0H0V_AA0acD6MethodOAA0acD9OperationOSDyS2SGAuA0acd7NetworkN0OAA0acd8ResponseN0VSSSgs6UInt64VtKFZ 48:s:11RadrootsKit0A28BackgroundTransferValidationO 0:
12:relationship 11:RadrootsKit 8:memberOf 295:s:11RadrootsKit0A25BackgroundTransferRequestV10identifier9remoteURL6method9operation7headers8metadata13networkPolicy08responseN020expectedSourceSHA25607maximumD5BytesAcA0acD10IdentifierV_10Foundation0H0VAA0acD6MethodOAA0acD9OperationOSDyS2SGAwA0acd7NetworkN0OAA0acd8ResponseN0VSSSgs6UInt64VtKcfc 45:s:11RadrootsKit0A25BackgroundTransferRequestV 0:
+12:relationship 11:RadrootsKit 8:memberOf 331:s:11RadrootsKit0A26BackgroundTransferSnapshotV7request5state8progress7failure8response18downloadedArtifact20possibleRemoteOrphan9updatedAt11executionID11uploadLeaseAcA0acD7RequestV_AA0acD5StateOAA0acD8ProgressVAA0acD7FailureOSgAA0acD8ResponseVSgAA0ac10DownloadedL0VSgSb10Foundation4DateVA1_4UUIDVSgAA0A19StagedBlobReferenceVSgtKcfc 46:s:11RadrootsKit0A26BackgroundTransferSnapshotV 0:
12:relationship 11:RadrootsKit 8:memberOf 37:s:11RadrootsKit0A9FileEntryV4nameSSvp 28:s:11RadrootsKit0A9FileEntryV 0:
12:relationship 11:RadrootsKit 8:memberOf 39:s:11RadrootsKit0A13NoopTelemetryVACycfc 33:s:11RadrootsKit0A13NoopTelemetryV 0:
12:relationship 11:RadrootsKit 8:memberOf 39:s:11RadrootsKit0A9FileScopeO4datayA2CmF 28:s:11RadrootsKit0A9FileScopeO 0:
@@ -1548,6 +1558,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A22BackgroundTaskSnapshotV17earliestBeginDate10Foundation0H0VSgvp 42:s:11RadrootsKit0A22BackgroundTaskSnapshotV 0:
12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A25BackgroundTransferRequestV014defaultMaximumD5Bytess6UInt64VvpZ 45:s:11RadrootsKit0A25BackgroundTransferRequestV 0:
12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A25BackgroundTransferRequestV14responsePolicyAA0acd8ResponseG0Vvp 45:s:11RadrootsKit0A25BackgroundTransferRequestV 0:
+12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A26BackgroundTransferSnapshotV11executionID10Foundation4UUIDVSgvp 46:s:11RadrootsKit0A26BackgroundTransferSnapshotV 0:
12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A28AppleBackgroundTaskSchedulerC8adaptersAcA0acdeF8AdaptersV_tcfc 48:s:11RadrootsKit0A28AppleBackgroundTaskSchedulerC 0:
12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A28AppleLoggerTelemetryAdaptersV4emitACyAA0acE9LogRecordVYbc_tcfc 48:s:11RadrootsKit0A28AppleLoggerTelemetryAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A29ApplePermissionStatusProviderC8adaptersAcA0acdE8AdaptersV_tcfc 49:s:11RadrootsKit0A29ApplePermissionStatusProviderC 0:
@@ -1571,7 +1582,6 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A29AppleMobileStoreConfigurationV14ownerDirectory10Foundation3URLVvp 49:s:11RadrootsKit0A29AppleMobileStoreConfigurationV 0:
12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A29UnavailableBackgroundTransferV9snapshotsSayAA0adE8SnapshotVGyYaKF 49:s:11RadrootsKit0A29UnavailableBackgroundTransferV 0:
12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A31AppleBackgroundTaskRegistrationV10identifierAA0adE10IdentifierVvp 51:s:11RadrootsKit0A31AppleBackgroundTaskRegistrationV 0:
-12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV7enqueueyyAA0adE7RequestVYaYbKcvp 51:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV3now10Foundation4DateVyYbcvp 56:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 85:s:11RadrootsKit0A14AppleFileRootsV17normalizedRootURL_5field10Foundation0H0VAH_SStKFZ 34:s:11RadrootsKit0A14AppleFileRootsV 0:
12:relationship 11:RadrootsKit 8:memberOf 85:s:11RadrootsKit0A18UserPresencePolicyO39deviceOwnerAuthenticationWithBiometricsyA2CmF 38:s:11RadrootsKit0A18UserPresencePolicyO 0:
@@ -1606,6 +1616,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A15AppleFileAccessC14readStagedBloby10Foundation4DataVAA0agH9ReferenceVKF 35:s:11RadrootsKit0A15AppleFileAccessC 0:
12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A15IdentityCustodyC21repairCorruptMetadata5labelAA0aC8SnapshotVSSSg_tYaKF 35:s:11RadrootsKit0A15IdentityCustodyC 0:
12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A24TelemetryRedactionPolicyV11replacement19maximumStringLengthACSS_Sitcfc 44:s:11RadrootsKit0A24TelemetryRedactionPolicyV 0:
+12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A26BackgroundTransferSnapshotV11uploadLeaseAA0A19StagedBlobReferenceVSgvp 46:s:11RadrootsKit0A26BackgroundTransferSnapshotV 0:
12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A27BackgroundTransferLocalFileO10stagedBlobyAcA0a6StagedH9ReferenceVcACmF 47:s:11RadrootsKit0A27BackgroundTransferLocalFileO 0:
12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A29UnavailableBackgroundTransferV5retryyAA0adE6HandleVAA0adE7RequestVYaKF 49:s:11RadrootsKit0A29UnavailableBackgroundTransferV 0:
12:relationship 11:RadrootsKit 8:memberOf 90:s:11RadrootsKit0A20AppleDocumentScannerC10fileAccess15callbackTimeoutAcA0ac4FileG0C_Sdtcfc 40:s:11RadrootsKit0A20AppleDocumentScannerC 0:
@@ -1651,6 +1662,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 97:s:11RadrootsKit0A29AppleMobileStoreConfigurationV27applicationSupportDirectory10Foundation3URLVvp 49:s:11RadrootsKit0A29AppleMobileStoreConfigurationV 0:
12:relationship 11:RadrootsKit 8:memberOf 97:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV6submityAA0adE8SnapshotVAA0adE7RequestVYaKF 54:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV 0:
12:relationship 11:RadrootsKit 8:memberOf 98:s:11RadrootsKit0A19StagedBlobReferenceV6blobID9sizeBytes9mediaType12filenameHintACSS_SiSSSgAHtKcfc 39:s:11RadrootsKit0A19StagedBlobReferenceV 0:
+12:relationship 11:RadrootsKit 8:memberOf 98:s:11RadrootsKit0A28AppleBackgroundTransferStoreC17admissionIsActive3forSbAA0adE10IdentifierV_tYaKF 48:s:11RadrootsKit0A28AppleBackgroundTransferStoreC 0:
12:relationship 11:RadrootsKit 8:memberOf 98:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV7resolvey10Foundation3URLVAA0ade5LocalF0OKF 55:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV 0:
12:relationship 11:RadrootsKit 8:memberOf 98:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV8registerySbAA0acdE12RegistrationVYaYbKcvp 56:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 99:s:11RadrootsKit0A23AppleBackgroundTransferC8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 43:s:11RadrootsKit0A23AppleBackgroundTransferC 0:
@@ -1732,15 +1744,18 @@ radroots.apple-kit.public-api.v1
12:relationship 18:RadrootsKitTesting 8:memberOf 106:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC8snapshot3for0aB00aF8SnapshotVAF0aF4KindO_tYaKF 59:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 107:s:18RadrootsKitTesting0A22FakeBackgroundTransferC8snapshot3for0aB00aeF8SnapshotVSgAF0aeF10IdentifierV_tYaKF 49:s:18RadrootsKitTesting0A22FakeBackgroundTransferC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 110:s:18RadrootsKitTesting0A15FakeMediaPickerC16setImportOutcomeyys6ResultOy0aB00aehJ0VAG0A18CaptureIntakeErrorOGF 42:s:18RadrootsKitTesting0A15FakeMediaPickerC 0:
+12:relationship 18:RadrootsKitTesting 8:memberOf 110:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC17admissionIsActive3forSb0aB00afG10IdentifierV_tYaKF 58:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 112:s:18RadrootsKitTesting0A20FakeLocationServicesC010setCurrentE7Outcomeyys6ResultOy0aB00aE7ReadingVAG0aeF5ErrorOGF 47:s:18RadrootsKitTesting0A20FakeLocationServicesC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 113:s:18RadrootsKitTesting0A29InMemoryIdentityMetadataStoreC14replaceRawData_3fory10Foundation0K0VSg_0aB00afG4SlotOtF 56:s:18RadrootsKitTesting0A29InMemoryIdentityMetadataStoreC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 118:s:18RadrootsKitTesting0A19FakeDocumentScannerC14setScanOutcomeyys6ResultOy0aB00a7ScannedE0VAG0A18CaptureIntakeErrorOGF 46:s:18RadrootsKitTesting0A19FakeDocumentScannerC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 120:s:18RadrootsKitTesting0A16FakeUserPresenceC6status19verificationOutcomeAC0aB00aeF6StatusV_s6ResultOySbAF0aeF5ErrorOGtcfc 43:s:18RadrootsKitTesting0A16FakeUserPresenceC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 120:s:18RadrootsKitTesting0A22FakeBackgroundTransferC6settle_12verificationy0aB00aeF10IdentifierV_AF0aeF12VerificationOtYaKF 49:s:18RadrootsKitTesting0A22FakeBackgroundTransferC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 123:s:18RadrootsKitTesting0A19InMemorySecureStoreC3put_3for6policyy10Foundation4DataV_0aB00afG3KeyVAJ0A18SecretAccessPolicyVtKF 46:s:18RadrootsKitTesting0A19InMemorySecureStoreC 0:
+12:relationship 18:RadrootsKitTesting 8:memberOf 123:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC23compareExchangeSnapshot8expected7desiredSb0aB00afgK0VSg_AItYaKF 58:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 124:s:18RadrootsKitTesting0A22FakeBackgroundTransferC015handleEventsForE10URLSession10identifier17completionHandlerySS_yyYbctYaF 49:s:18RadrootsKitTesting0A22FakeBackgroundTransferC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 128:s:18RadrootsKitTesting0A25UITestLaunchConfigurationV13deterministic11environment9arguments8language6localeACSDyS2SG_SaySSGS2StFZ 52:s:18RadrootsKitTesting0A25UITestLaunchConfigurationV 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 128:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC03setG0_3for10observedAty0aB00afG0O_AG0aF4KindO10Foundation4DateVSgtF 59:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC 0:
+12:relationship 18:RadrootsKitTesting 8:memberOf 137:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC13withAdmission3for9operation0aB00afG6HandleVAG0afG10IdentifierV_AIyYaYbKctYaKF 58:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 142:s:18RadrootsKitTesting0A19FakeDocumentScannerC7support11scanOutcomeAC0aB00aeF7SupportV_s6ResultOyAF0a7ScannedE0VAF0A18CaptureIntakeErrorOGtcfc 46:s:18RadrootsKitTesting0A19FakeDocumentScannerC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 143:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC8statuses07defaultG010observedAtACSDy0aB00aF4KindOAG0afG0OG_AK10Foundation4DateVtcfc 59:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC 0:
12:relationship 18:RadrootsKitTesting 8:memberOf 148:s:18RadrootsKitTesting0A22FakeBackgroundTransferC5store14enqueueOutcome9updatedAtAC0aB00aeF5Store_p_s6ResultOyytAG0aeF5ErrorOG10Foundation4DateVtcfc 49:s:18RadrootsKitTesting0A22FakeBackgroundTransferC 0:
@@ -1941,12 +1956,12 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 10:swift.init 198:s:11RadrootsKit0A22BackgroundTaskSnapshotV10identifier4kind17earliestBeginDate11submittedAt27requiresNetworkConnectivity0M13ExternalPowerAcA0acD10IdentifierV_AA0acD4KindO10Foundation0J0VSgAPS2btKcfc 102:init(identifier:kind:earliestBeginDate:submittedAt:requiresNetworkConnectivity:requiresExternalPower:) 231:init(identifier: RadrootsBackgroundTaskIdentifier, kind: RadrootsBackgroundTaskKind, earliestBeginDate: Date? = nil, submittedAt: Date = Date(), requiresNetworkConnectivity: Bool = false, requiresExternalPower: Bool = false) throws
6:symbol 11:RadrootsKit 10:swift.init 202:s:11RadrootsKit0A15ScannedDocumentV4file10outputKind17suggestedFilename9mediaType9pageCount9sizeBytes10capturedAtAcA0A13FileReferenceV_AA0ad13ScannerOutputG0OS2Ss6UInt16Vs6UInt64V10Foundation4DateVtKcfc 81:init(file:outputKind:suggestedFilename:mediaType:pageCount:sizeBytes:capturedAt:) 189:init(file: RadrootsFileReference, outputKind: RadrootsDocumentScannerOutputKind, suggestedFilename: String, mediaType: String, pageCount: UInt16, sizeBytes: UInt64, capturedAt: Date) throws
6:symbol 11:RadrootsKit 10:swift.init 213:s:11RadrootsKit0A10MediaAssetV6source4kind4file9mediaType17suggestedFilename9sizeBytes10pixelWidth0N6Height10capturedAtAcA0aC6SourceO_AA0aC4KindOAA0A13FileReferenceVS2Ss6UInt64Vs6UInt32VSgAW10Foundation4DateVtKcfc 95:init(source:kind:file:mediaType:suggestedFilename:sizeBytes:pixelWidth:pixelHeight:capturedAt:) 232:init(source: RadrootsMediaSource, kind: RadrootsMediaKind, file: RadrootsFileReference, mediaType: String, suggestedFilename: String, sizeBytes: UInt64, pixelWidth: UInt32? = nil, pixelHeight: UInt32? = nil, capturedAt: Date) throws
-6:symbol 11:RadrootsKit 10:swift.init 213:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV3now7enqueue6cancel06activeE11Identifiers06handleD6EventsAC10Foundation4DateVyYbc_yAA0adE7RequestVYaYbKcyAA0adE10IdentifierVYaYbKcShyAOGyYaYbKcySS_yyYbctYaYbctcfc 74:init(now:enqueue:cancel:activeTransferIdentifiers:handleBackgroundEvents:) 438:init(now: @escaping @Sendable () -> Date = Date.init, enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void, cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void, activeTransferIdentifiers: @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>, handleBackgroundEvents: @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void)
6:symbol 11:RadrootsKit 10:swift.init 220:s:11RadrootsKit0A15IdentityCustodyC13configuration11secureStore08metadataG012userPresence13protectedData3nowAcA0acD13ConfigurationV_AA0a6SecureG0_pAA0ac8MetadataG0_pAA0a4UserJ0_pAA0a9ProtectedL8ProviderVs6UInt64VyYbctcfc 77:init(configuration:secureStore:metadataStore:userPresence:protectedData:now:) 353:init(configuration: RadrootsIdentityCustodyConfiguration, secureStore: any RadrootsSecureStore, metadataStore: any RadrootsIdentityMetadataStore, userPresence: any RadrootsUserPresence, protectedData: RadrootsProtectedDataProvider = .available, now: @escaping @Sendable () -> UInt64 = {\n UInt64(Date().timeIntervalSince1970 * 1000)\n })
+6:symbol 11:RadrootsKit 10:swift.init 223:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV3now7enqueue6cancel06activeE11Identifiers06handleD6EventsAC10Foundation4DateVyYbc_yAA0adE7RequestV_AI4UUIDVtYaYbKcyAA0adE10IdentifierVYaYbKcShyAQGyYaYbKcySS_yyYbctYaYbctcfc 74:init(now:enqueue:cancel:activeTransferIdentifiers:handleBackgroundEvents:) 444:init(now: @escaping @Sendable () -> Date = Date.init, enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest, UUID) async throws -> Void, cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void, activeTransferIdentifiers: @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>, handleBackgroundEvents: @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void)
6:symbol 11:RadrootsKit 10:swift.init 228:s:11RadrootsKit0A29AppleLocationServicesAdaptersV3now08locationE7Enabled19authorizationStatus29requestWhenInUseAuthorization0l7CurrentD0AC10Foundation4DateVyYbc_SbyYbcAA0adP0OyYbcAMSdYaYbKcAA0aD7ReadingVAA0aqD7RequestVYaYbKctcfc 107:init(now:locationServicesEnabled:authorizationStatus:requestWhenInUseAuthorization:requestCurrentLocation:) 419:init(now: @escaping @Sendable () -> Date = Date.init, locationServicesEnabled: @escaping @Sendable () -> Bool, authorizationStatus: @escaping @Sendable () -> RadrootsLocationAuthorization, requestWhenInUseAuthorization: @escaping @Sendable (TimeInterval) async throws -> RadrootsLocationAuthorization, requestCurrentLocation: @escaping @Sendable (RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading)
6:symbol 11:RadrootsKit 10:swift.init 243:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV3now8register6submit6cancel0K3All12pendingTasksAC10Foundation4DateVyYbc_SbAA0acdE12RegistrationVYaYbKcyAA0adE7RequestVYaYbKcyAA0adE10IdentifierVYaYbcyyYaYbcSayAA0adE8SnapshotVGyYaYbKctcfc 56:init(now:register:submit:cancel:cancelAll:pendingTasks:) 443:init(now: @escaping @Sendable () -> Date = Date.init, register: @escaping @Sendable (RadrootsAppleBackgroundTaskRegistration) async throws -> Bool, submit: @escaping @Sendable (RadrootsBackgroundTaskRequest) async throws -> Void, cancel: @escaping @Sendable (RadrootsBackgroundTaskIdentifier) async -> Void, cancelAll: @escaping @Sendable () async -> Void, pendingTasks: @escaping @Sendable () async throws -> [RadrootsBackgroundTaskSnapshot])
-6:symbol 11:RadrootsKit 10:swift.init 266:s:11RadrootsKit0A26BackgroundTransferSnapshotV7request5state8progress7failure8response18downloadedArtifact20possibleRemoteOrphan9updatedAtAcA0acD7RequestV_AA0acD5StateOAA0acD8ProgressVAA0acD7FailureOSgAA0acD8ResponseVSgAA0ac10DownloadedL0VSgSb10Foundation4DateVtKcfc 96:init(request:state:progress:failure:response:downloadedArtifact:possibleRemoteOrphan:updatedAt:) 390:init(request: RadrootsBackgroundTransferRequest, state: RadrootsBackgroundTransferState = .queued, progress: RadrootsBackgroundTransferProgress = .zero, failure: RadrootsBackgroundTransferFailure? = nil, response: RadrootsBackgroundTransferResponse? = nil, downloadedArtifact: RadrootsBackgroundDownloadedArtifact? = nil, possibleRemoteOrphan: Bool = false, updatedAt: Date = Date()) throws
6:symbol 11:RadrootsKit 10:swift.init 295:s:11RadrootsKit0A25BackgroundTransferRequestV10identifier9remoteURL6method9operation7headers8metadata13networkPolicy08responseN020expectedSourceSHA25607maximumD5BytesAcA0acD10IdentifierV_10Foundation0H0VAA0acD6MethodOAA0acD9OperationOSDyS2SGAwA0acd7NetworkN0OAA0acd8ResponseN0VSSSgs6UInt64VtKcfc 132:init(identifier:remoteURL:method:operation:headers:metadata:networkPolicy:responsePolicy:expectedSourceSHA256:maximumTransferBytes:) 493:init(identifier: RadrootsBackgroundTransferIdentifier = .generated(), remoteURL: URL, method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation, headers: [String : String] = [:], metadata: [String : String] = [:], networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS, responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard, expectedSourceSHA256: String? = nil, maximumTransferBytes: UInt64 = Self.defaultMaximumTransferBytes) throws
+6:symbol 11:RadrootsKit 10:swift.init 331:s:11RadrootsKit0A26BackgroundTransferSnapshotV7request5state8progress7failure8response18downloadedArtifact20possibleRemoteOrphan9updatedAt11executionID11uploadLeaseAcA0acD7RequestV_AA0acD5StateOAA0acD8ProgressVAA0acD7FailureOSgAA0acD8ResponseVSgAA0ac10DownloadedL0VSgSb10Foundation4DateVA1_4UUIDVSgAA0A19StagedBlobReferenceVSgtKcfc 120:init(request:state:progress:failure:response:downloadedArtifact:possibleRemoteOrphan:updatedAt:executionID:uploadLease:) 465:init(request: RadrootsBackgroundTransferRequest, state: RadrootsBackgroundTransferState = .queued, progress: RadrootsBackgroundTransferProgress = .zero, failure: RadrootsBackgroundTransferFailure? = nil, response: RadrootsBackgroundTransferResponse? = nil, downloadedArtifact: RadrootsBackgroundDownloadedArtifact? = nil, possibleRemoteOrphan: Bool = false, updatedAt: Date = Date(), executionID: UUID? = nil, uploadLease: RadrootsStagedBlobReference? = nil) throws
6:symbol 11:RadrootsKit 10:swift.init 39:s:11RadrootsKit0A13NoopTelemetryVACycfc 6:init() 6:init()
6:symbol 11:RadrootsKit 10:swift.init 47:s:11RadrootsKit0A18IdentityPassphraseVyACSSKcfc 8:init(_:) 28:init(_ value: String) throws
6:symbol 11:RadrootsKit 10:swift.init 48:s:11RadrootsKit0A9MediaKindO8rawValueACSgSS_tcfc 15:init(rawValue:) 23:init?(rawValue: String)
@@ -2057,12 +2072,16 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 12:swift.method 105:s:11RadrootsKit0A15AppleFileAccessC17installStagedBlob_9referencey10Foundation4DataV_AA0agH9ReferenceVtKF 31:installStagedBlob(_:reference:) 83:func installStagedBlob(_ data: Data, reference: RadrootsStagedBlobReference) throws
6:symbol 11:RadrootsKit 12:swift.method 105:s:11RadrootsKit0A29UnavailableBackgroundTransferV8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 14:snapshot(for:) 110:func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot?
6:symbol 11:RadrootsKit 12:swift.method 106:s:11RadrootsKit0A11SecureStoreP3put_3for6policyy10Foundation4DataV_AA0acD3KeyVAA0A18SecretAccessPolicyVtKF 18:put(_:for:policy:) 99:func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws
+6:symbol 11:RadrootsKit 12:swift.method 106:s:11RadrootsKit0A23BackgroundTransferStoreP23compareExchangeSnapshot8expected7desiredSbAA0acdH0VSg_AHtYaKF 42:compareExchangeSnapshot(expected:desired:) 141:func compareExchangeSnapshot(expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot) async throws -> Bool
+6:symbol 11:RadrootsKit 12:swift.method 106:s:11RadrootsKit0A30BackgroundTransferFileResolverP18releaseUploadLease11executionIDy10Foundation4UUIDV_tKF 32:releaseUploadLease(executionID:) 49:func releaseUploadLease(executionID: UUID) throws
6:symbol 11:RadrootsKit 12:swift.method 107:s:11RadrootsKit0A18BackgroundTransferP6settle_12verificationyAA0acD10IdentifierV_AA0acD12VerificationOtYaKF 23:settle(_:verification:) 130:func settle(_ identifier: RadrootsBackgroundTransferIdentifier, verification: RadrootsBackgroundTransferVerification) async throws
6:symbol 11:RadrootsKit 12:swift.method 108:s:11RadrootsKit0A10FileAccessP05stageC0_9mediaType12filenameHintAA0A19StagedBlobReferenceVAA0acL0V_SSSgAKtKF 36:stageFile(_:mediaType:filenameHint:) 145:@discardableResult func stageFile(_ file: RadrootsFileReference, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference
6:symbol 11:RadrootsKit 12:swift.method 109:s:11RadrootsKit0A14AppleFileRootsV11resolvedURL3for18allowRootDirectory10Foundation0G0VAA0aD9ReferenceV_SbtKF 36:resolvedURL(for:allowRootDirectory:) 97:func resolvedURL(for file: RadrootsFileReference, allowRootDirectory: Bool = false) throws -> URL
6:symbol 11:RadrootsKit 12:swift.method 109:s:11RadrootsKit0A30BackgroundTransferFileResolverP4read_12maximumBytes10Foundation4DataVAA0acd5LocalE0O_SitKF 21:read(_:maximumBytes:) 88:func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data
6:symbol 11:RadrootsKit 12:swift.method 110:s:11RadrootsKit0A15AppleFileAccessC16sweepStagedBlobs9olderThanSayAA0aG13BlobReferenceVG10Foundation4DateV_tKF 28:sweepStagedBlobs(olderThan:) 104:@discardableResult func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference]
6:symbol 11:RadrootsKit 12:swift.method 110:s:11RadrootsKit0A15IdentityCustodyC013migrateLegacyC04from5labelAA0aC8SnapshotVAA0A14SecureStoreKeyV_SSSgtYaKF 34:migrateLegacyIdentity(from:label:) 148:@discardableResult func migrateLegacyIdentity(from legacyKey: RadrootsSecureStoreKey, label: String? = nil) async throws -> RadrootsIdentitySnapshot
+6:symbol 11:RadrootsKit 12:swift.method 111:s:11RadrootsKit0A28AppleBackgroundTransferStoreC23compareExchangeSnapshot8expected7desiredSbAA0adeI0VSg_AHtYaKF 42:compareExchangeSnapshot(expected:desired:) 141:func compareExchangeSnapshot(expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot) async throws -> Bool
+6:symbol 11:RadrootsKit 12:swift.method 111:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV18releaseUploadLease11executionIDy10Foundation4UUIDV_tKF 32:releaseUploadLease(executionID:) 49:func releaseUploadLease(executionID: UUID) throws
6:symbol 11:RadrootsKit 12:swift.method 112:s:11RadrootsKit0A23AppleBackgroundTransferC6settle_12verificationyAA0adE10IdentifierV_AA0adE12VerificationOtYaKF 23:settle(_:verification:) 130:func settle(_ identifier: RadrootsBackgroundTransferIdentifier, verification: RadrootsBackgroundTransferVerification) async throws
6:symbol 11:RadrootsKit 12:swift.method 113:s:11RadrootsKit0A15AppleFileAccessC05stageD0_9mediaType12filenameHintAA0A19StagedBlobReferenceVAA0adM0V_SSSgAKtKF 36:stageFile(_:mediaType:filenameHint:) 157:@discardableResult func stageFile(_ file: RadrootsFileReference, mediaType: String? = nil, filenameHint: String? = nil) throws -> RadrootsStagedBlobReference
6:symbol 11:RadrootsKit 12:swift.method 113:s:11RadrootsKit0A18BackgroundTransferP015handleEventsForC10URLSession10identifier17completionHandlerySS_yyYbctYaF 66:handleEventsForBackgroundURLSession(identifier:completionHandler:) 117:func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async
@@ -2074,9 +2093,11 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 12:swift.method 118:s:11RadrootsKit0A23AppleBackgroundTransferC015handleEventsForD10URLSession10identifier17completionHandlerySS_yyYbctYaF 66:handleEventsForBackgroundURLSession(identifier:completionHandler:) 117:func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async
6:symbol 11:RadrootsKit 12:swift.method 118:s:11RadrootsKit0A29UnavailableBackgroundTransferV6settle_12verificationyAA0adE10IdentifierV_AA0adE12VerificationOtYaKF 23:settle(_:verification:) 121:func settle(_: RadrootsBackgroundTransferIdentifier, verification _: RadrootsBackgroundTransferVerification) async throws
6:symbol 11:RadrootsKit 12:swift.method 119:s:11RadrootsKit0A24AppleKeychainSecureStoreC3put_3for6policyy10Foundation4DataV_AA0aeF3KeyVAA0A18SecretAccessPolicyVtKF 18:put(_:for:policy:) 120:func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy = .secureLocalSecret) throws
+6:symbol 11:RadrootsKit 12:swift.method 120:s:11RadrootsKit0A23BackgroundTransferStoreP13withAdmission3for9operationAA0acD6HandleVAA0acD10IdentifierV_AHyYaYbKctYaKF 29:withAdmission(for:operation:) 205:func withAdmission(for identifier: RadrootsBackgroundTransferIdentifier, operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle) async throws -> RadrootsBackgroundTransferHandle
6:symbol 11:RadrootsKit 12:swift.method 121:s:11RadrootsKit0A15AppleFileAccessC9stageBlob_9mediaType12filenameHintAA0a6StagedG9ReferenceV10Foundation4DataV_SSSgALtKF 36:stageBlob(_:mediaType:filenameHint:) 140:@discardableResult func stageBlob(_ data: Data, mediaType: String? = nil, filenameHint: String? = nil) throws -> RadrootsStagedBlobReference
6:symbol 11:RadrootsKit 12:swift.method 123:s:11RadrootsKit0A26PreparedExportFileDocumentV11fileWrapper13configurationSo06NSFileH0C7SwiftUI0eF18WriteConfigurationV_tKF 27:fileWrapper(configuration:) 110:func fileWrapper(configuration _: RadrootsPreparedExportFileDocument.WriteConfiguration) throws -> FileWrapper
6:symbol 11:RadrootsKit 12:swift.method 124:s:11RadrootsKit0A29UnavailableBackgroundTransferV015handleEventsForD10URLSession10identifier17completionHandlerySS_yyYbctYaF 66:handleEventsForBackgroundURLSession(identifier:completionHandler:) 119:func handleEventsForBackgroundURLSession(identifier _: String, completionHandler: @escaping @Sendable () -> Void) async
+6:symbol 11:RadrootsKit 12:swift.method 125:s:11RadrootsKit0A28AppleBackgroundTransferStoreC13withAdmission3for9operationAA0adE6HandleVAA0adE10IdentifierV_AHyYaYbKctYaKF 29:withAdmission(for:operation:) 205:func withAdmission(for identifier: RadrootsBackgroundTransferIdentifier, operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle) async throws -> RadrootsBackgroundTransferHandle
6:symbol 11:RadrootsKit 12:swift.method 126:s:11RadrootsKit0A10FileAccessP013stageExternalC0_9mediaType12filenameHintAA0A19StagedBlobReferenceV10Foundation3URLV_SSSgALtKF 44:stageExternalFile(_:mediaType:filenameHint:) 140:@discardableResult func stageExternalFile(_ sourceURL: URL, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference
6:symbol 11:RadrootsKit 12:swift.method 131:s:11RadrootsKit0A15AppleFileAccessC013stageExternalD0_9mediaType12filenameHintAA0A19StagedBlobReferenceV10Foundation3URLV_SSSgALtKF 44:stageExternalFile(_:mediaType:filenameHint:) 152:@discardableResult func stageExternalFile(_ sourceURL: URL, mediaType: String? = nil, filenameHint: String? = nil) throws -> RadrootsStagedBlobReference
6:symbol 11:RadrootsKit 12:swift.method 146:s:11RadrootsKit0A10FileAccessP012copyExternalC0_2to9mediaType17suggestedFilenameAA0A16ImportedDocumentV10Foundation3URLV_AA0aC9ReferenceVSSSgAOtKF 51:copyExternalFile(_:to:mediaType:suggestedFilename:) 173:@discardableResult func copyExternalFile(_ sourceURL: URL, to file: RadrootsFileReference, mediaType: String?, suggestedFilename: String?) throws -> RadrootsImportedDocument
@@ -2084,7 +2105,9 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 12:swift.method 152:s:11RadrootsKit0A15IdentityCustodyC014importPortableC0_10passphrase15replaceExistingAA0aC8SnapshotVAA0aC19PortabilityEnvelopeV_AA0aC10PassphraseVSbtYaKF 53:importPortableIdentity(_:passphrase:replaceExisting:) 207:@discardableResult func importPortableIdentity(_ envelope: RadrootsIdentityPortabilityEnvelope, passphrase: RadrootsIdentityPassphrase, replaceExisting: Bool = false) async throws -> RadrootsIdentitySnapshot
6:symbol 11:RadrootsKit 12:swift.method 154:s:7SwiftUI4ViewP11RadrootsKitE24radrootsDocumentExporter14preparedExport12onCompletionQrAA7BindingVyAD0d8PreparedjG0VSgG_ys6ResultOyAD0djgO0Vs5Error_pGctF 54:radrootsDocumentExporter(preparedExport:onCompletion:) 195:@MainActor func radrootsDocumentExporter(preparedExport: Binding<RadrootsPreparedExportDocument?>, onCompletion: @escaping (Result<RadrootsExportDocumentResult, any Error>) -> Void) -> some View\n
6:symbol 11:RadrootsKit 12:swift.method 159:s:11RadrootsKit0A18OpaqueSignerBridgeC6submit_10completionAA0acD12CancellationCAA0aC11SignRequestV_ys6ResultOyAA0aC9SignatureVAA0A20IdentityCustodyErrorOGYbctF 21:submit(_:completion:) 209:@discardableResult func submit(_ request: RadrootsOpaqueSignRequest, completion: @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void) -> RadrootsOpaqueSignerCancellation
+6:symbol 11:RadrootsKit 12:swift.method 171:s:11RadrootsKit0A30BackgroundTransferFileResolverP18prepareUploadLease3for11executionID8existingAA0a10StagedBlobI0VAA0acD7RequestV_10Foundation4UUIDVAA0anO9ReferenceVSgtKF 45:prepareUploadLease(for:executionID:existing:) 164:func prepareUploadLease(for request: RadrootsBackgroundTransferRequest, executionID: UUID, existing: RadrootsStagedBlobReference?) throws -> RadrootsStagedBlobLease
6:symbol 11:RadrootsKit 12:swift.method 175:s:7SwiftUI4ViewP11RadrootsKitE24radrootsDocumentImporter7request10fileAccess12onCompletionQrAA7BindingVyAD0dG13ImportRequestVSgG_AD0d4FileK0_pys6ResultOyAD0dgoR0Vs5Error_pGctF 58:radrootsDocumentImporter(request:fileAccess:onCompletion:) 223:@MainActor func radrootsDocumentImporter(request: Binding<RadrootsDocumentImportRequest?>, fileAccess: any RadrootsFileAccess, onCompletion: @escaping (Result<RadrootsDocumentImportResult, any Error>) -> Void) -> some View\n
+6:symbol 11:RadrootsKit 12:swift.method 176:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV18prepareUploadLease3for11executionID8existingAA0a10StagedBlobJ0VAA0adE7RequestV_10Foundation4UUIDVAA0aoP9ReferenceVSgtKF 45:prepareUploadLease(for:executionID:existing:) 164:func prepareUploadLease(for request: RadrootsBackgroundTransferRequest, executionID: UUID, existing: RadrootsStagedBlobReference?) throws -> RadrootsStagedBlobLease
6:symbol 11:RadrootsKit 12:swift.method 229:s:11RadrootsKit0A18AppleMediaPreparerC20blossomUploadRequest13preparedImage9remoteURL13authorization13networkPolicy10identifierAA0a18BackgroundTransferH0VAA0ac8PreparedJ0V_10Foundation0L0VSSAA0aqr7NetworkO0OAA0aqR10IdentifierVtKF 85:blossomUploadRequest(preparedImage:remoteURL:authorization:networkPolicy:identifier:) 287:func blossomUploadRequest(preparedImage: RadrootsApplePreparedImage, remoteURL: URL, authorization: String, networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS, identifier: RadrootsBackgroundTransferIdentifier = .generated()) throws -> RadrootsBackgroundTransferRequest
6:symbol 11:RadrootsKit 12:swift.method 46:s:11RadrootsKit0A15IdentityCustodyC04lockC0yyF 14:lockIdentity() 19:func lockIdentity()
6:symbol 11:RadrootsKit 12:swift.method 51:s:11RadrootsKit0A14SecureStoreKeyV10normalizedACyKF 12:normalized() 50:func normalized() throws -> RadrootsSecureStoreKey
@@ -2215,6 +2238,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 12:swift.method 91:s:11RadrootsKit0A29UnavailableBackgroundTransferV7enqueueyAA0adE6HandleVAA0adE7RequestVYaKF 11:enqueue(_:) 99:func enqueue(_: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle
6:symbol 11:RadrootsKit 12:swift.method 92:s:11RadrootsKit0A15AppleFileAccessC13prepareExportyAA0a8PreparedG8DocumentVAA0agI7RequestVKF 17:prepareExport(_:) 120:@discardableResult func prepareExport(_ request: RadrootsExportDocumentRequest) throws -> RadrootsPreparedExportDocument
6:symbol 11:RadrootsKit 12:swift.method 93:s:11RadrootsKit0A21AppleLocationServicesC07currentD0yAA0a7CurrentD6ResultVAA0agD7RequestVYaKF 19:currentLocation(_:) 109:func currentLocation(_ request: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult
+6:symbol 11:RadrootsKit 12:swift.method 93:s:11RadrootsKit0A23BackgroundTransferStoreP17admissionIsActive3forSbAA0acD10IdentifierV_tYaKF 23:admissionIsActive(for:) 97:func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool
6:symbol 11:RadrootsKit 12:swift.method 93:s:11RadrootsKit0A30BackgroundTransferFileResolverP7resolvey10Foundation3URLVAA0acd5LocalE0OKF 11:resolve(_:) 71:func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL
6:symbol 11:RadrootsKit 12:swift.method 93:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV12pendingTasksSayAA0adE8SnapshotVGyYaKF 14:pendingTasks() 68:func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot]
6:symbol 11:RadrootsKit 12:swift.method 94:s:11RadrootsKit0A18BackgroundTransferP8snapshot3forAA0acD8SnapshotVSgAA0acD10IdentifierV_tYaKF 14:snapshot(for:) 119:func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot?
@@ -2225,6 +2249,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 12:swift.method 96:s:11RadrootsKit0A20AppleExternalActionsC7canOpenyAA0aD16ActionCapabilityVAA0adH11DestinationVYaF 11:canOpen(_:) 104:func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability
6:symbol 11:RadrootsKit 12:swift.method 97:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV6submityAA0adE8SnapshotVAA0adE7RequestVYaKF 10:submit(_:) 92:func submit(_: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot
6:symbol 11:RadrootsKit 12:swift.method 98:s:11RadrootsKit0A24PermissionStatusProviderP9snapshots3forSayAA0aC8SnapshotVGSayAA0aC4KindOG_tYaKF 15:snapshots(for:) 96:func snapshots(for kinds: [RadrootsPermissionKind]) async throws -> [RadrootsPermissionSnapshot]
+6:symbol 11:RadrootsKit 12:swift.method 98:s:11RadrootsKit0A28AppleBackgroundTransferStoreC17admissionIsActive3forSbAA0adE10IdentifierV_tYaKF 23:admissionIsActive(for:) 97:func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool
6:symbol 11:RadrootsKit 12:swift.method 98:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV7resolvey10Foundation3URLVAA0ade5LocalF0OKF 11:resolve(_:) 71:func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL
6:symbol 11:RadrootsKit 12:swift.method 99:s:11RadrootsKit0A23AppleBackgroundTransferC8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 14:snapshot(for:) 119:func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot?
6:symbol 11:RadrootsKit 12:swift.struct 28:s:11RadrootsKit0A9FileEntryV 17:RadrootsFileEntry 24:struct RadrootsFileEntry
@@ -2316,6 +2341,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 13:swift.func.op 49:s:11RadrootsKit0A14TelemetryLevelO1loiySbAC_ACtFZ 7:<(_:_:) 80:static func < (lhs: RadrootsTelemetryLevel, rhs: RadrootsTelemetryLevel) -> Bool
6:symbol 11:RadrootsKit 13:swift.func.op 59:s:11RadrootsKit0A24BackgroundTaskIdentifierV1loiySbAC_ACtFZ 7:<(_:_:) 100:static func < (lhs: RadrootsBackgroundTaskIdentifier, rhs: RadrootsBackgroundTaskIdentifier) -> Bool
6:symbol 11:RadrootsKit 13:swift.func.op 63:s:11RadrootsKit0A28BackgroundTransferIdentifierV1loiySbAC_ACtFZ 7:<(_:_:) 108:static func < (lhs: RadrootsBackgroundTransferIdentifier, rhs: RadrootsBackgroundTransferIdentifier) -> Bool
+6:symbol 11:RadrootsKit 14:swift.property 104:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV7enqueueyyAA0adE7RequestV_10Foundation4UUIDVtYaYbKcvp 7:enqueue 85:let enqueue: @Sendable (RadrootsBackgroundTransferRequest, UUID) async throws -> Void
6:symbol 11:RadrootsKit 14:swift.property 105:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV06activeE11IdentifiersShyAA0adE10IdentifierVGyYaYbKcvp 25:activeTransferIdentifiers 101:let activeTransferIdentifiers: @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>
6:symbol 11:RadrootsKit 14:swift.property 106:s:11RadrootsKit0A29AppleLocationServicesAdaptersV014requestCurrentD0yAA0aD7ReadingVAA0ahD7RequestVYaYbKcvp 22:requestCurrentLocation 110:let requestCurrentLocation: @Sendable (RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading
6:symbol 11:RadrootsKit 14:swift.property 37:s:11RadrootsKit0A9FileEntryV4nameSSvp 4:name 16:let name: String
@@ -2586,12 +2612,12 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 14:swift.property 81:s:11RadrootsKit0A17ShareTransferItemV14preparedExportAA0a8PreparedG8DocumentVSgvp 14:preparedExport 59:var preparedExport: RadrootsPreparedExportDocument? { get }
6:symbol 11:RadrootsKit 14:swift.property 81:s:11RadrootsKit0A22BackgroundTaskSnapshotV17earliestBeginDate10Foundation0H0VSgvp 17:earliestBeginDate 28:let earliestBeginDate: Date?
6:symbol 11:RadrootsKit 14:swift.property 81:s:11RadrootsKit0A25BackgroundTransferRequestV14responsePolicyAA0acd8ResponseG0Vvp 14:responsePolicy 60:let responsePolicy: RadrootsBackgroundTransferResponsePolicy
+6:symbol 11:RadrootsKit 14:swift.property 81:s:11RadrootsKit0A26BackgroundTransferSnapshotV11executionID10Foundation4UUIDVSgvp 11:executionID 22:let executionID: UUID?
6:symbol 11:RadrootsKit 14:swift.property 82:s:11RadrootsKit0A28AppleExternalActionsAdaptersV7openURLySb10Foundation0H0VYaYbcvp 7:openURL 42:let openURL: @Sendable (URL) async -> Bool
6:symbol 11:RadrootsKit 14:swift.property 82:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV012notificationE0AA0adE0OyYaYbcvp 18:notificationStatus 70:let notificationStatus: @Sendable () async -> RadrootsPermissionStatus
6:symbol 11:RadrootsKit 14:swift.property 83:s:11RadrootsKit0A22DocumentScannerSupportV20supportedOutputKindsSayAA0acdG4KindOGvp 20:supportedOutputKinds 61:let supportedOutputKinds: [RadrootsDocumentScannerOutputKind]
6:symbol 11:RadrootsKit 14:swift.property 84:s:11RadrootsKit0A29AppleMobileStoreConfigurationV14ownerDirectory10Foundation3URLVvp 14:ownerDirectory 23:let ownerDirectory: URL
6:symbol 11:RadrootsKit 14:swift.property 84:s:11RadrootsKit0A31AppleBackgroundTaskRegistrationV10identifierAA0adE10IdentifierVvp 10:identifier 48:let identifier: RadrootsBackgroundTaskIdentifier
-6:symbol 11:RadrootsKit 14:swift.property 84:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV7enqueueyyAA0adE7RequestVYaYbKcvp 7:enqueue 79:let enqueue: @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void
6:symbol 11:RadrootsKit 14:swift.property 84:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV3now10Foundation4DateVyYbcvp 3:now 29:let now: @Sendable () -> Date
6:symbol 11:RadrootsKit 14:swift.property 85:s:11RadrootsKit0A28IdentityCustodyConfigurationV12secretPolicyAA0a12SecretAccessG0Vvp 12:secretPolicy 44:let secretPolicy: RadrootsSecretAccessPolicy
6:symbol 11:RadrootsKit 14:swift.property 85:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV06handleD6EventsyySS_yyYbctYaYbcvp 22:handleBackgroundEvents 92:let handleBackgroundEvents: @Sendable (String, @escaping @Sendable () -> Void) async -> Void
@@ -2600,6 +2626,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 14:swift.property 86:s:11RadrootsKit0A28LocationServicesAvailabilityV32canRequestWhenInUseAuthorizationSbvp 32:canRequestWhenInUseAuthorization 50:var canRequestWhenInUseAuthorization: Bool { get }
6:symbol 11:RadrootsKit 14:swift.property 87:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV6cancelyyAA0adE10IdentifierVYaYbKcvp 6:cancel 81:let cancel: @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void
6:symbol 11:RadrootsKit 14:swift.property 88:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV6submityyAA0adE7RequestVYaYbKcvp 6:submit 74:let submit: @Sendable (RadrootsBackgroundTaskRequest) async throws -> Void
+6:symbol 11:RadrootsKit 14:swift.property 89:s:11RadrootsKit0A26BackgroundTransferSnapshotV11uploadLeaseAA0A19StagedBlobReferenceVSgvp 11:uploadLease 45:let uploadLease: RadrootsStagedBlobReference?
6:symbol 11:RadrootsKit 14:swift.property 90:s:11RadrootsKit0A25AppleUserPresenceAdaptersV6verifyyAA0adE6ResultVAA0adE7RequestVYaYbKcvp 6:verify 94:let verify: @Sendable (RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult
6:symbol 11:RadrootsKit 14:swift.property 90:s:11RadrootsKit0A26BackgroundTransferSnapshotV18downloadedArtifactAA0ac10DownloadedG0VSgvp 18:downloadedArtifact 61:let downloadedArtifact: RadrootsBackgroundDownloadedArtifact?
6:symbol 11:RadrootsKit 14:swift.property 90:s:11RadrootsKit0A28AppleExternalActionsAdaptersV14appSettingsURL10Foundation0I0VSgyYaYbcvp 14:appSettingsURL 46:let appSettingsURL: @Sendable () async -> URL?
@@ -2992,13 +3019,16 @@ radroots.apple-kit.public-api.v1
6:symbol 18:RadrootsKitTesting 12:swift.method 106:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC8snapshot3for0aB00aF8SnapshotVAF0aF4KindO_tYaKF 14:snapshot(for:) 90:func snapshot(for kind: RadrootsPermissionKind) async throws -> RadrootsPermissionSnapshot
6:symbol 18:RadrootsKitTesting 12:swift.method 107:s:18RadrootsKitTesting0A22FakeBackgroundTransferC8snapshot3for0aB00aeF8SnapshotVSgAF0aeF10IdentifierV_tYaKF 14:snapshot(for:) 119:func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot?
6:symbol 18:RadrootsKitTesting 12:swift.method 110:s:18RadrootsKitTesting0A15FakeMediaPickerC16setImportOutcomeyys6ResultOy0aB00aehJ0VAG0A18CaptureIntakeErrorOGF 20:setImportOutcome(_:) 95:func setImportOutcome(_ outcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>)
+6:symbol 18:RadrootsKitTesting 12:swift.method 110:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC17admissionIsActive3forSb0aB00afG10IdentifierV_tYaKF 23:admissionIsActive(for:) 97:func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool
6:symbol 18:RadrootsKitTesting 12:swift.method 112:s:18RadrootsKitTesting0A20FakeLocationServicesC010setCurrentE7Outcomeyys6ResultOy0aB00aE7ReadingVAG0aeF5ErrorOGF 29:setCurrentLocationOutcome(_:) 105:func setCurrentLocationOutcome(_ outcome: Result<RadrootsLocationReading, RadrootsLocationServicesError>)
6:symbol 18:RadrootsKitTesting 12:swift.method 113:s:18RadrootsKitTesting0A29InMemoryIdentityMetadataStoreC14replaceRawData_3fory10Foundation0K0VSg_0aB00afG4SlotOtF 22:replaceRawData(_:for:) 74:func replaceRawData(_ data: Data?, for slot: RadrootsIdentityMetadataSlot)
6:symbol 18:RadrootsKitTesting 12:swift.method 118:s:18RadrootsKitTesting0A19FakeDocumentScannerC14setScanOutcomeyys6ResultOy0aB00a7ScannedE0VAG0A18CaptureIntakeErrorOGF 18:setScanOutcome(_:) 91:func setScanOutcome(_ outcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>)
6:symbol 18:RadrootsKitTesting 12:swift.method 120:s:18RadrootsKitTesting0A22FakeBackgroundTransferC6settle_12verificationy0aB00aeF10IdentifierV_AF0aeF12VerificationOtYaKF 23:settle(_:verification:) 130:func settle(_ identifier: RadrootsBackgroundTransferIdentifier, verification: RadrootsBackgroundTransferVerification) async throws
6:symbol 18:RadrootsKitTesting 12:swift.method 123:s:18RadrootsKitTesting0A19InMemorySecureStoreC3put_3for6policyy10Foundation4DataV_0aB00afG3KeyVAJ0A18SecretAccessPolicyVtKF 18:put(_:for:policy:) 99:func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws
+6:symbol 18:RadrootsKitTesting 12:swift.method 123:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC23compareExchangeSnapshot8expected7desiredSb0aB00afgK0VSg_AItYaKF 42:compareExchangeSnapshot(expected:desired:) 141:func compareExchangeSnapshot(expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot) async throws -> Bool
6:symbol 18:RadrootsKitTesting 12:swift.method 124:s:18RadrootsKitTesting0A22FakeBackgroundTransferC015handleEventsForE10URLSession10identifier17completionHandlerySS_yyYbctYaF 66:handleEventsForBackgroundURLSession(identifier:completionHandler:) 117:func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async
6:symbol 18:RadrootsKitTesting 12:swift.method 128:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC03setG0_3for10observedAty0aB00afG0O_AG0aF4KindO10Foundation4DateVSgtF 28:setStatus(_:for:observedAt:) 109:func setStatus(_ status: RadrootsPermissionStatus, for kind: RadrootsPermissionKind, observedAt: Date? = nil)
+6:symbol 18:RadrootsKitTesting 12:swift.method 137:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC13withAdmission3for9operation0aB00afG6HandleVAG0afG10IdentifierV_AIyYaYbKctYaKF 29:withAdmission(for:operation:) 205:func withAdmission(for identifier: RadrootsBackgroundTransferIdentifier, operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle) async throws -> RadrootsBackgroundTransferHandle
6:symbol 18:RadrootsKitTesting 12:swift.method 54:s:18RadrootsKitTesting0A18RecordingTelemetryC5resetyyF 7:reset() 12:func reset()
6:symbol 18:RadrootsKitTesting 12:swift.method 67:s:18RadrootsKitTesting0A16FakeUserPresenceC9setStatusyy0aB00aefH0VF 13:setStatus(_:) 52:func setStatus(_ status: RadrootsUserPresenceStatus)
6:symbol 18:RadrootsKitTesting 12:swift.method 68:s:18RadrootsKitTesting0A15FakeMediaPickerC10setSupportyy0aB00aefH0VF 14:setSupport(_:) 54:func setSupport(_ support: RadrootsMediaPickerSupport)