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 d03a020cf684a868ec643e162acab241e9370dc4
parent 6633fca85507c17bd68103a1d39730b593a5a147
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 17:04:57 -0700

apple-kit: add background task scheduler contracts

- add Swift-native background task request and snapshot models
- add unavailable scheduler behavior for unsupported platforms
- add RadrootsKitTesting fake scheduler coverage
- verify contracts with focused Swift tests

Diffstat:
ASources/RadrootsKit/RadrootsBackgroundTasks.swift | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKitTesting/RadrootsBackgroundTaskTesting.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTestingTests/RadrootsBackgroundTaskTestingTests.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsBackgroundTaskTests.swift | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 434 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsBackgroundTasks.swift b/Sources/RadrootsKit/RadrootsBackgroundTasks.swift @@ -0,0 +1,196 @@ +import Foundation + +public enum RadrootsBackgroundTaskKind: String, Sendable, Equatable, Hashable, CaseIterable { + case appRefresh + case processing +} + +public enum RadrootsBackgroundTaskError: Error, Equatable, Sendable { + case invalidRequest(String) + case unavailable(String) + case schedulerFailure(String) +} + +extension RadrootsBackgroundTaskError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + case .unavailable(let message): + message + case .schedulerFailure(let message): + message + } + } +} + +public struct RadrootsBackgroundTaskIdentifier: Sendable, Equatable, Hashable, Comparable { + public let rawValue: String + + public init(_ value: String) throws { + self.rawValue = try RadrootsBackgroundTaskValidation.normalizedIdentifier(value) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +public struct RadrootsBackgroundTaskRequest: Sendable, Equatable, Hashable { + public let identifier: RadrootsBackgroundTaskIdentifier + public let kind: RadrootsBackgroundTaskKind + public let earliestBeginDate: Date? + public let requiresNetworkConnectivity: Bool + public let requiresExternalPower: Bool + + public init( + identifier: RadrootsBackgroundTaskIdentifier, + kind: RadrootsBackgroundTaskKind, + earliestBeginDate: Date? = nil, + requiresNetworkConnectivity: Bool = false, + requiresExternalPower: Bool = false + ) throws { + try RadrootsBackgroundTaskValidation.validate( + kind: kind, + earliestBeginDate: earliestBeginDate, + requiresNetworkConnectivity: requiresNetworkConnectivity, + requiresExternalPower: requiresExternalPower + ) + self.identifier = identifier + self.kind = kind + self.earliestBeginDate = earliestBeginDate + self.requiresNetworkConnectivity = requiresNetworkConnectivity + self.requiresExternalPower = requiresExternalPower + } + + public init( + identifier: String, + kind: RadrootsBackgroundTaskKind, + earliestBeginDate: Date? = nil, + requiresNetworkConnectivity: Bool = false, + requiresExternalPower: Bool = false + ) throws { + try self.init( + identifier: RadrootsBackgroundTaskIdentifier(identifier), + kind: kind, + earliestBeginDate: earliestBeginDate, + requiresNetworkConnectivity: requiresNetworkConnectivity, + requiresExternalPower: requiresExternalPower + ) + } +} + +public struct RadrootsBackgroundTaskSnapshot: Sendable, Equatable, Hashable { + public let identifier: RadrootsBackgroundTaskIdentifier + public let kind: RadrootsBackgroundTaskKind + public let earliestBeginDate: Date? + public let submittedAt: Date + public let requiresNetworkConnectivity: Bool + public let requiresExternalPower: Bool + + public init( + identifier: RadrootsBackgroundTaskIdentifier, + kind: RadrootsBackgroundTaskKind, + earliestBeginDate: Date? = nil, + submittedAt: Date = Date(), + requiresNetworkConnectivity: Bool = false, + requiresExternalPower: Bool = false + ) throws { + try RadrootsBackgroundTaskValidation.validate( + kind: kind, + earliestBeginDate: earliestBeginDate, + requiresNetworkConnectivity: requiresNetworkConnectivity, + requiresExternalPower: requiresExternalPower + ) + guard submittedAt.timeIntervalSinceReferenceDate.isFinite else { + throw RadrootsBackgroundTaskError.invalidRequest("background task submitted date must be finite") + } + self.identifier = identifier + self.kind = kind + self.earliestBeginDate = earliestBeginDate + self.submittedAt = submittedAt + self.requiresNetworkConnectivity = requiresNetworkConnectivity + self.requiresExternalPower = requiresExternalPower + } + + public init(request: RadrootsBackgroundTaskRequest, submittedAt: Date = Date()) throws { + try self.init( + identifier: request.identifier, + kind: request.kind, + earliestBeginDate: request.earliestBeginDate, + submittedAt: submittedAt, + requiresNetworkConnectivity: request.requiresNetworkConnectivity, + requiresExternalPower: request.requiresExternalPower + ) + } +} + +public protocol RadrootsBackgroundTaskScheduler: Sendable { + func submit(_ request: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot + func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) async throws + func cancelAll() async throws + func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot] +} + +public struct RadrootsUnavailableBackgroundTaskScheduler: RadrootsBackgroundTaskScheduler, Sendable { + private let reason: String + + public init(reason: String = "background task scheduling is unavailable on this platform") { + let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + self.reason = trimmedReason.isEmpty ? "background task scheduling is unavailable on this platform" : trimmedReason + } + + public func submit(_ request: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot { + throw RadrootsBackgroundTaskError.unavailable(reason) + } + + public func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) async throws { + throw RadrootsBackgroundTaskError.unavailable(reason) + } + + public func cancelAll() async throws { + throw RadrootsBackgroundTaskError.unavailable(reason) + } + + public func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot] { + throw RadrootsBackgroundTaskError.unavailable(reason) + } +} + +public enum RadrootsBackgroundTaskValidation { + public static func normalizedIdentifier(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { + throw RadrootsBackgroundTaskError.invalidRequest("background task identifier must not be empty") + } + guard trimmed.count <= 255 else { + throw RadrootsBackgroundTaskError.invalidRequest("background task identifier is too long") + } + guard trimmed.range( + of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", + options: .regularExpression + ) != nil else { + throw RadrootsBackgroundTaskError.invalidRequest("background task identifier must use lowercase safe identifier characters") + } + guard !trimmed.contains("..") else { + throw RadrootsBackgroundTaskError.invalidRequest("background task identifier cannot contain empty path components") + } + return trimmed + } + + public static func validate( + kind: RadrootsBackgroundTaskKind, + earliestBeginDate: Date?, + requiresNetworkConnectivity: Bool, + requiresExternalPower: Bool + ) throws { + if let earliestBeginDate { + guard earliestBeginDate.timeIntervalSinceReferenceDate.isFinite else { + throw RadrootsBackgroundTaskError.invalidRequest("background task earliest begin date must be finite") + } + } + guard kind == .processing || (!requiresNetworkConnectivity && !requiresExternalPower) else { + throw RadrootsBackgroundTaskError.invalidRequest("app refresh tasks cannot require network connectivity or external power") + } + } +} diff --git a/Sources/RadrootsKitTesting/RadrootsBackgroundTaskTesting.swift b/Sources/RadrootsKitTesting/RadrootsBackgroundTaskTesting.swift @@ -0,0 +1,72 @@ +import Foundation +import RadrootsKit + +public actor RadrootsFakeBackgroundTaskScheduler: RadrootsBackgroundTaskScheduler { + private var pendingTaskSnapshots: [RadrootsBackgroundTaskIdentifier: RadrootsBackgroundTaskSnapshot] + private var submittedRequestsValue: [RadrootsBackgroundTaskRequest] + private var cancelledIdentifiersValue: [RadrootsBackgroundTaskIdentifier] + private var cancelAllCountValue: Int + private var submitOutcome: Result<Void, RadrootsBackgroundTaskError> + private let submittedAt: Date + + public init( + pendingTasks: [RadrootsBackgroundTaskSnapshot] = [], + submitOutcome: Result<Void, RadrootsBackgroundTaskError> = .success(()), + submittedAt: Date = Date(timeIntervalSince1970: 0) + ) { + self.pendingTaskSnapshots = Dictionary(uniqueKeysWithValues: pendingTasks.map { ($0.identifier, $0) }) + self.submittedRequestsValue = [] + self.cancelledIdentifiersValue = [] + self.cancelAllCountValue = 0 + self.submitOutcome = submitOutcome + self.submittedAt = submittedAt + } + + public func setSubmitOutcome(_ outcome: Result<Void, RadrootsBackgroundTaskError>) { + submitOutcome = outcome + } + + public func submit(_ request: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot { + submittedRequestsValue.append(request) + switch submitOutcome { + case .success: + let snapshot = try RadrootsBackgroundTaskSnapshot(request: request, submittedAt: submittedAt) + pendingTaskSnapshots[request.identifier] = snapshot + return snapshot + case .failure(let error): + throw error + } + } + + public func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) async throws { + cancelledIdentifiersValue.append(identifier) + pendingTaskSnapshots.removeValue(forKey: identifier) + } + + public func cancelAll() async throws { + cancelAllCountValue += 1 + pendingTaskSnapshots.removeAll() + } + + public func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot] { + pendingTaskSnapshots.values.sorted { lhs, rhs in + lhs.identifier < rhs.identifier + } + } + + public var submittedRequests: [RadrootsBackgroundTaskRequest] { + submittedRequestsValue + } + + public var submittedRequestCount: Int { + submittedRequestsValue.count + } + + public var cancelledIdentifiers: [RadrootsBackgroundTaskIdentifier] { + cancelledIdentifiersValue + } + + public var cancelAllCount: Int { + cancelAllCountValue + } +} diff --git a/Tests/RadrootsKitTestingTests/RadrootsBackgroundTaskTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsBackgroundTaskTestingTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +import RadrootsKit +import RadrootsKitTesting + +@Test func fakeBackgroundTaskSchedulerRecordsSubmittedRequestsAndPendingTasks() async throws { + let scheduler = RadrootsFakeBackgroundTaskScheduler( + submittedAt: Date(timeIntervalSince1970: 20) + ) + let request = try RadrootsBackgroundTaskRequest( + identifier: "org.radroots.field-ios.background.refresh", + kind: .appRefresh, + earliestBeginDate: Date(timeIntervalSince1970: 30) + ) + + let snapshot = try await scheduler.submit(request) + + #expect(snapshot.identifier == request.identifier) + #expect(snapshot.submittedAt == Date(timeIntervalSince1970: 20)) + #expect(await scheduler.submittedRequestCount == 1) + #expect(await scheduler.submittedRequests == [request]) + #expect(try await scheduler.pendingTasks() == [snapshot]) +} + +@Test func fakeBackgroundTaskSchedulerCancelsIndividualAndAllTasks() async throws { + let first = try RadrootsBackgroundTaskRequest( + identifier: "org.radroots.field-ios.background.processing", + kind: .processing + ) + let second = try RadrootsBackgroundTaskRequest( + identifier: "org.radroots.field-ios.background.refresh", + kind: .appRefresh + ) + let scheduler = RadrootsFakeBackgroundTaskScheduler() + _ = try await scheduler.submit(first) + _ = try await scheduler.submit(second) + + try await scheduler.cancel(first.identifier) + + #expect(await scheduler.cancelledIdentifiers == [first.identifier]) + #expect(try await scheduler.pendingTasks().map(\.identifier) == [second.identifier]) + + try await scheduler.cancelAll() + + #expect(await scheduler.cancelAllCount == 1) + #expect(try await scheduler.pendingTasks().isEmpty) +} + +@Test func fakeBackgroundTaskSchedulerCanReturnSubmitFailures() async throws { + let scheduler = RadrootsFakeBackgroundTaskScheduler( + submitOutcome: .failure(.schedulerFailure("scheduler rejected request")) + ) + let request = try RadrootsBackgroundTaskRequest( + identifier: "org.radroots.field-ios.background.refresh", + kind: .appRefresh + ) + + await #expect(throws: RadrootsBackgroundTaskError.schedulerFailure("scheduler rejected request")) { + _ = try await scheduler.submit(request) + } + #expect(await scheduler.submittedRequests == [request]) + #expect(try await scheduler.pendingTasks().isEmpty) +} diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTaskTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTaskTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func backgroundTaskIdentifierNormalizesAndRejectsUnsafeValues() throws { + let identifier = try RadrootsBackgroundTaskIdentifier(" ORG.RADROOTS.FIELD-IOS.refresh ") + + #expect(identifier.rawValue == "org.radroots.field-ios.refresh") + + #expect(throws: RadrootsBackgroundTaskError.invalidRequest("background task identifier must not be empty")) { + _ = try RadrootsBackgroundTaskIdentifier(" ") + } + #expect(throws: RadrootsBackgroundTaskError.invalidRequest("background task identifier must use lowercase safe identifier characters")) { + _ = try RadrootsBackgroundTaskIdentifier(".org.radroots") + } + #expect(throws: RadrootsBackgroundTaskError.invalidRequest("background task identifier cannot contain empty path components")) { + _ = try RadrootsBackgroundTaskIdentifier("org.radroots..refresh") + } +} + +@Test func backgroundTaskRequestValidatesKindSpecificOptions() throws { + let refresh = try RadrootsBackgroundTaskRequest( + identifier: "org.radroots.field-ios.background.refresh", + kind: .appRefresh, + earliestBeginDate: Date(timeIntervalSince1970: 10) + ) + + #expect(refresh.kind == .appRefresh) + #expect(refresh.earliestBeginDate == Date(timeIntervalSince1970: 10)) + #expect(!refresh.requiresNetworkConnectivity) + + let processing = try RadrootsBackgroundTaskRequest( + identifier: "org.radroots.field-ios.background.processing", + kind: .processing, + requiresNetworkConnectivity: true, + requiresExternalPower: true + ) + + #expect(processing.kind == .processing) + #expect(processing.requiresNetworkConnectivity) + #expect(processing.requiresExternalPower) + + #expect(throws: RadrootsBackgroundTaskError.invalidRequest("app refresh tasks cannot require network connectivity or external power")) { + _ = try RadrootsBackgroundTaskRequest( + identifier: "org.radroots.field-ios.background.refresh", + kind: .appRefresh, + requiresNetworkConnectivity: true + ) + } + #expect(throws: RadrootsBackgroundTaskError.invalidRequest("background task earliest begin date must be finite")) { + _ = try RadrootsBackgroundTaskRequest( + identifier: "org.radroots.field-ios.background.refresh", + kind: .appRefresh, + earliestBeginDate: Date(timeIntervalSinceReferenceDate: .infinity) + ) + } +} + +@Test func backgroundTaskSnapshotPreservesRequestValues() throws { + let request = try RadrootsBackgroundTaskRequest( + identifier: "org.radroots.field-ios.background.processing", + kind: .processing, + earliestBeginDate: Date(timeIntervalSince1970: 5), + requiresNetworkConnectivity: true + ) + let snapshot = try RadrootsBackgroundTaskSnapshot( + request: request, + submittedAt: Date(timeIntervalSince1970: 7) + ) + + #expect(snapshot.identifier == request.identifier) + #expect(snapshot.kind == .processing) + #expect(snapshot.earliestBeginDate == Date(timeIntervalSince1970: 5)) + #expect(snapshot.submittedAt == Date(timeIntervalSince1970: 7)) + #expect(snapshot.requiresNetworkConnectivity) + #expect(!snapshot.requiresExternalPower) + + #expect(throws: RadrootsBackgroundTaskError.invalidRequest("background task submitted date must be finite")) { + _ = try RadrootsBackgroundTaskSnapshot( + request: request, + submittedAt: Date(timeIntervalSinceReferenceDate: .infinity) + ) + } +} + +@Test func unavailableBackgroundTaskSchedulerThrowsTypedErrors() async throws { + let scheduler = RadrootsUnavailableBackgroundTaskScheduler(reason: "missing background support") + let identifier = try RadrootsBackgroundTaskIdentifier("org.radroots.field-ios.background.refresh") + let request = try RadrootsBackgroundTaskRequest(identifier: identifier, kind: .appRefresh) + + await #expect(throws: RadrootsBackgroundTaskError.unavailable("missing background support")) { + _ = try await scheduler.submit(request) + } + await #expect(throws: RadrootsBackgroundTaskError.unavailable("missing background support")) { + try await scheduler.cancel(identifier) + } + await #expect(throws: RadrootsBackgroundTaskError.unavailable("missing background support")) { + try await scheduler.cancelAll() + } + await #expect(throws: RadrootsBackgroundTaskError.unavailable("missing background support")) { + _ = try await scheduler.pendingTasks() + } +}