commit 15ed98b2e88ddf2f99f6efc6ac8dde9bb1ffe557
parent f1a3379e210255b247b8fb04dfc17af6bdc128e8
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 20:28:43 -0700
apple-kit: harden live background execution
- complete BGTask requests after async handlers finish
- add delegate-backed background transfer coordination
- move completed downloads into resolved AppleKit destinations
- cover transfer completion, failure, progress, and event handlers
Diffstat:
3 files changed, 641 insertions(+), 23 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTasks.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTasks.swift
@@ -52,10 +52,18 @@ public struct RadrootsAppleBackgroundTaskSchedulerAdapters: Sendable {
forTaskWithIdentifier: registration.identifier.rawValue,
using: nil
) { task in
+ let completion = RadrootsAppleBackgroundTaskCompletion(task: task)
+ let handlerTask = Task {
+ await registration.handler()
+ }
+ task.expirationHandler = {
+ handlerTask.cancel()
+ completion.complete(success: false)
+ }
Task {
- _ = await registration.handler()
+ let success = await handlerTask.value
+ completion.complete(success: success)
}
- task.setTaskCompleted(success: true)
}
},
submit: { request in
@@ -182,3 +190,25 @@ private extension RadrootsAppleBackgroundTaskSchedulerAdapters {
}
}
#endif
+
+#if canImport(BackgroundTasks) && os(iOS)
+private final class RadrootsAppleBackgroundTaskCompletion: @unchecked Sendable {
+ private let task: BGTask
+ private let lock = NSLock()
+ private var completed = false
+
+ init(task: BGTask) {
+ self.task = task
+ }
+
+ func complete(success: Bool) {
+ lock.lock()
+ defer { lock.unlock() }
+ guard !completed else {
+ return
+ }
+ completed = true
+ task.setTaskCompleted(success: success)
+ }
+}
+#endif
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift
@@ -38,14 +38,24 @@ public struct RadrootsAppleBackgroundTransferAdapters: Sendable {
public static func live(
sessionIdentifier: String,
- fileResolver: any RadrootsBackgroundTransferFileResolver
+ 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)
+ let session = RadrootsAppleBackgroundURLSession(
+ identifier: normalizedSessionIdentifier,
+ store: store,
+ fileResolver: fileResolver,
+ downloadStagingRoot: downloadStagingRoot,
+ now: now
+ )
return Self(
+ now: now,
enqueue: { request in
- try await session.enqueue(request, fileResolver: fileResolver)
+ try await session.enqueue(request)
},
cancel: { identifier in
await session.cancel(identifier)
@@ -79,10 +89,23 @@ public final class RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer,
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/\(try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier))/downloads"
+ ),
+ allowRootDirectory: true
+ )
try self.init(
- store: RadrootsAppleBackgroundTransferStore(roots: roots),
- adapters: .live(sessionIdentifier: sessionIdentifier, fileResolver: resolver)
+ store: store,
+ adapters: .live(
+ sessionIdentifier: sessionIdentifier,
+ store: store,
+ fileResolver: resolver,
+ downloadStagingRoot: downloadStagingRoot
+ )
)
}
@@ -96,13 +119,16 @@ public final class RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer,
)
do {
try await adapters.enqueue(request)
- try await store.saveSnapshot(
- try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: .running,
- updatedAt: adapters.now()
+ let current = try await store.loadSnapshots().first { $0.identifier == request.identifier }
+ if current?.state == .queued {
+ try await store.saveSnapshot(
+ try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .running,
+ updatedAt: adapters.now()
+ )
)
- )
+ }
return RadrootsBackgroundTransferHandle(request: request)
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
@@ -168,20 +194,263 @@ public final class RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer,
}
}
+enum RadrootsStagedBackgroundDownloadResult: Sendable, Equatable {
+ case file(URL)
+ case failure(String)
+}
+
+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 completionHandlersByIdentifier: [String: @Sendable () -> Void]
+
+ 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
+ self.completionHandlersByIdentifier = [:]
+ }
+
+ 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,
+ errorMessage: existing.errorMessage,
+ updatedAt: now()
+ )
+ )
+ }
+
+ func complete(
+ identifier: RadrootsBackgroundTransferIdentifier,
+ platformError: Error?,
+ stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?,
+ bytesTransferred: Int64,
+ totalBytesExpected: Int64?
+ ) async {
+ guard let existing = try? await snapshot(for: identifier), existing.state != .cancelled else {
+ return
+ }
+ if let platformError {
+ await fail(existing: existing, message: Self.failureMessage(for: platformError))
+ return
+ }
+ switch existing.request.operation {
+ case .download(let destination):
+ await completeDownload(
+ existing: existing,
+ destination: destination,
+ stagedDownloadResult: stagedDownloadResult,
+ bytesTransferred: bytesTransferred,
+ totalBytesExpected: totalBytesExpected
+ )
+ case .upload:
+ await completeUpload(
+ existing: existing,
+ bytesTransferred: bytesTransferred,
+ totalBytesExpected: totalBytesExpected
+ )
+ }
+ }
+
+ func handleBackgroundEvents(
+ identifier: String,
+ completionHandler: @escaping @Sendable () -> Void
+ ) {
+ guard identifier == sessionIdentifier else {
+ completionHandler()
+ return
+ }
+ completionHandlersByIdentifier[identifier] = completionHandler
+ }
+
+ func finishBackgroundEvents(identifier: String?) {
+ guard identifier == nil || identifier == sessionIdentifier else {
+ return
+ }
+ completionHandlersByIdentifier.removeValue(forKey: sessionIdentifier)?()
+ }
+
+ private func completeUpload(
+ existing: RadrootsBackgroundTransferSnapshot,
+ 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: .completed,
+ progress: progress,
+ updatedAt: now()
+ )
+ )
+ }
+
+ private func completeDownload(
+ existing: RadrootsBackgroundTransferSnapshot,
+ destination: RadrootsBackgroundTransferLocalFile,
+ stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?,
+ bytesTransferred: Int64,
+ totalBytesExpected: Int64?
+ ) async {
+ guard case .file(let stagedFileURL) = stagedDownloadResult else {
+ let message: String
+ if case .failure(let failureMessage) = stagedDownloadResult {
+ message = failureMessage
+ } else {
+ message = "background download finished without a staged file"
+ }
+ await fail(existing: existing, message: message)
+ return
+ }
+ do {
+ let destinationURL = try fileResolver.resolve(destination)
+ try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
+ if fileManager.fileExists(atPath: destinationURL.path) {
+ try fileManager.removeItem(at: destinationURL)
+ }
+ try fileManager.moveItem(at: stagedFileURL, to: destinationURL)
+ let fileSize = try Self.fileSize(at: destinationURL, fileManager: fileManager)
+ let progress = Self.progress(
+ bytesTransferred: max(bytesTransferred, fileSize),
+ totalBytesExpected: totalBytesExpected,
+ fallback: existing.progress
+ ) ?? existing.progress
+ try await store.saveSnapshot(
+ try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .completed,
+ progress: progress,
+ updatedAt: now()
+ )
+ )
+ } catch {
+ await fail(existing: existing, message: Self.failureMessage(for: error))
+ }
+ }
+
+ private func fail(existing: RadrootsBackgroundTransferSnapshot, message: String) async {
+ try? await store.saveSnapshot(
+ try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .failed,
+ progress: existing.progress,
+ errorMessage: Self.sanitizedMessage(message),
+ 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 failureMessage(for error: Error) -> String {
+ if let localized = error as? LocalizedError,
+ let description = localized.errorDescription {
+ return sanitizedMessage(description)
+ }
+ return sanitizedMessage(String(describing: error))
+ }
+
+ private static func sanitizedMessage(_ value: String) -> String {
+ let scalars = value.unicodeScalars.map { scalar in
+ CharacterSet.controlCharacters.contains(scalar) ? UnicodeScalar(32)! : scalar
+ }
+ let withoutControls = String(String.UnicodeScalarView(scalars))
+ let trimmed = withoutControls.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ return "background transfer failed"
+ }
+ return String(trimmed.prefix(240))
+ }
+}
+
#if os(iOS)
private actor RadrootsAppleBackgroundURLSession {
private let identifier: String
- private var completionHandlersByIdentifier: [String: @Sendable () -> Void]
+ private let fileResolver: any RadrootsBackgroundTransferFileResolver
+ private let downloadStagingRoot: URL
+ private let coordinator: RadrootsAppleBackgroundTransferCoordinator
+ private let fileManager: FileManager
+ private var session: URLSession?
+ private var sessionDelegate: RadrootsAppleBackgroundURLSessionDelegate?
+ private var sessionDelegateQueue: OperationQueue?
- init(identifier: String) {
+ init(
+ identifier: String,
+ store: any RadrootsBackgroundTransferStore,
+ fileResolver: any RadrootsBackgroundTransferFileResolver,
+ downloadStagingRoot: URL,
+ now: @escaping @Sendable () -> Date
+ ) {
self.identifier = identifier
- self.completionHandlersByIdentifier = [:]
+ self.fileResolver = fileResolver
+ self.downloadStagingRoot = downloadStagingRoot
+ self.fileManager = .default
+ self.coordinator = RadrootsAppleBackgroundTransferCoordinator(
+ sessionIdentifier: identifier,
+ store: store,
+ fileResolver: fileResolver,
+ now: now
+ )
}
- func enqueue(
- _ request: RadrootsBackgroundTransferRequest,
- fileResolver: any RadrootsBackgroundTransferFileResolver
- ) async throws {
+ func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws {
let session = backgroundSession()
var urlRequest = URLRequest(url: request.remoteURL)
urlRequest.httpMethod = request.method.rawValue
@@ -220,8 +489,8 @@ private actor RadrootsAppleBackgroundURLSession {
func handleBackgroundEvents(
identifier: String,
completionHandler: @escaping @Sendable () -> Void
- ) {
- completionHandlersByIdentifier[identifier] = completionHandler
+ ) async {
+ await coordinator.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler)
}
private func allTasks() async -> [URLSessionTask] {
@@ -233,10 +502,169 @@ private actor RadrootsAppleBackgroundURLSession {
}
private func backgroundSession() -> URLSession {
+ if let session {
+ return session
+ }
let configuration = URLSessionConfiguration.background(withIdentifier: identifier)
configuration.sessionSendsLaunchEvents = true
configuration.isDiscretionary = false
- return URLSession(configuration: configuration)
+ 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
+ self.sessionDelegate = delegate
+ self.sessionDelegateQueue = delegateQueue
+ return session
+ }
+}
+
+private final class RadrootsAppleBackgroundURLSessionDelegate: NSObject, URLSessionDownloadDelegate, URLSessionTaskDelegate, @unchecked Sendable {
+ private let coordinator: RadrootsAppleBackgroundTransferCoordinator
+ private let downloadStagingRoot: URL
+ private let fileManager: FileManager
+ private let lock = NSLock()
+ private var stagedDownloadResultsByTaskIdentifier: [Int: RadrootsStagedBackgroundDownloadResult]
+
+ init(
+ coordinator: RadrootsAppleBackgroundTransferCoordinator,
+ downloadStagingRoot: URL,
+ fileManager: FileManager
+ ) {
+ self.coordinator = coordinator
+ self.downloadStagingRoot = downloadStagingRoot
+ self.fileManager = fileManager
+ self.stagedDownloadResultsByTaskIdentifier = [:]
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didFinishDownloadingTo location: URL
+ ) {
+ guard let identifier = transferIdentifier(from: downloadTask) else {
+ return
+ }
+ let result: RadrootsStagedBackgroundDownloadResult
+ do {
+ 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)
+ result = .file(destination)
+ } catch {
+ result = .failure(Self.failureMessage(for: error))
+ }
+ recordDownloadResult(result, taskIdentifier: downloadTask.taskIdentifier)
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didWriteData bytesWritten: Int64,
+ totalBytesWritten: Int64,
+ totalBytesExpectedToWrite: Int64
+ ) {
+ guard let identifier = transferIdentifier(from: downloadTask) else {
+ return
+ }
+ Task {
+ await coordinator.updateProgress(
+ identifier: identifier,
+ bytesTransferred: totalBytesWritten,
+ totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToWrite)
+ )
+ }
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ didSendBodyData bytesSent: Int64,
+ totalBytesSent: Int64,
+ totalBytesExpectedToSend: Int64
+ ) {
+ guard let identifier = transferIdentifier(from: task) else {
+ return
+ }
+ Task {
+ await coordinator.updateProgress(
+ identifier: identifier,
+ bytesTransferred: totalBytesSent,
+ totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend)
+ )
+ }
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ didCompleteWithError error: Error?
+ ) {
+ guard let identifier = transferIdentifier(from: task) else {
+ return
+ }
+ let bytesTransferred = max(max(task.countOfBytesReceived, task.countOfBytesSent), 0)
+ let expected = Self.expectedByteCount(max(task.countOfBytesExpectedToReceive, task.countOfBytesExpectedToSend))
+ let stagedDownloadResult = takeDownloadResult(taskIdentifier: task.taskIdentifier)
+ Task {
+ await coordinator.complete(
+ identifier: identifier,
+ platformError: error,
+ stagedDownloadResult: stagedDownloadResult,
+ bytesTransferred: bytesTransferred,
+ totalBytesExpected: expected
+ )
+ }
+ }
+
+ func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
+ Task {
+ await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier)
+ }
+ }
+
+ 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)
+ }
+
+ private func transferIdentifier(from task: URLSessionTask) -> RadrootsBackgroundTransferIdentifier? {
+ guard let taskDescription = task.taskDescription else {
+ return nil
+ }
+ return try? RadrootsBackgroundTransferIdentifier(taskDescription)
+ }
+
+ private static func expectedByteCount(_ value: Int64) -> Int64? {
+ value >= 0 ? value : nil
+ }
+
+ private static func failureMessage(for error: Error) -> String {
+ if let localized = error as? LocalizedError,
+ let description = localized.errorDescription {
+ return description
+ }
+ return String(describing: error)
}
}
#endif
diff --git a/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift
@@ -89,6 +89,144 @@ import RadrootsKitTesting
#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),
+ 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 == .completed)
+ #expect(snapshot?.progress.bytesTransferred == Int64(payload.count))
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 500))
+ #expect(try Data(contentsOf: destination) == payload)
+ #expect(!FileManager.default.fileExists(atPath: stagedFile.path))
+}
+
+@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(
+ try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .running,
+ updatedAt: Date(timeIntervalSince1970: 1)
+ )
+ )
+
+ await coordinator.complete(
+ identifier: request.identifier,
+ platformError: nil,
+ stagedDownloadResult: .failure("temporary file unavailable"),
+ bytesTransferred: 0,
+ totalBytesExpected: nil
+ )
+
+ let snapshot = try await store.loadSnapshots().first
+ #expect(snapshot?.state == .failed)
+ #expect(snapshot?.errorMessage == "temporary file unavailable")
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 600))
+}
+
+@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(
+ try 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,
+ bytesTransferred: 10,
+ totalBytesExpected: 10
+ )
+
+ let snapshot = try await store.loadSnapshots().first
+ #expect(snapshot?.state == .completed)
+ #expect(snapshot?.progress.bytesTransferred == 10)
+ #expect(snapshot?.progress.totalBytesExpected == 10)
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 700))
+}
+
+@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 unrelated = RadrootsCompletionProbe()
+
+ await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") {
+ completion.markCompleted()
+ }
+ #expect(!completion.completed)
+
+ await coordinator.handleBackgroundEvents(identifier: "other.session") {
+ unrelated.markCompleted()
+ }
+ #expect(unrelated.completed)
+
+ await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer")
+ #expect(completion.completed)
+}
+
private actor RadrootsAppleBackgroundTransferProbe {
private let nowValue: Date
private let enqueueOutcome: Result<Void, RadrootsBackgroundTransferError>
@@ -192,3 +330,25 @@ private func appleTransferRequest(identifier: String) throws -> RadrootsBackgrou
)
)
}
+
+private func appleUploadRequest(identifier: String) 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"))
+ )
+ )
+}
+
+private func appleTransferRoots() throws -> RadrootsAppleFileRoots {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("radroots-apple-background-transfer-\(UUID().uuidString)", 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)
+ )
+}