apple_kit

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

commit 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:
MSources/RadrootsKit/RadrootsAppleBackgroundTasks.swift | 34++++++++++++++++++++++++++++++++--
MSources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift | 470+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MTests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + ) +}