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:
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()
+ }
+}