RadrootsBackgroundTasks.swift (7674B)
1 import Foundation 2 3 public enum RadrootsBackgroundTaskKind: String, Sendable, Equatable, Hashable, CaseIterable { 4 case appRefresh 5 case processing 6 } 7 8 public enum RadrootsBackgroundTaskError: Error, Equatable, Sendable { 9 case invalidRequest(String) 10 case unavailable(String) 11 case schedulerFailure(String) 12 } 13 14 extension RadrootsBackgroundTaskError: LocalizedError { 15 public var errorDescription: String? { 16 switch self { 17 case .invalidRequest(let message): 18 message 19 case .unavailable(let message): 20 message 21 case .schedulerFailure(let message): 22 message 23 } 24 } 25 } 26 27 public struct RadrootsBackgroundTaskIdentifier: Sendable, Equatable, Hashable, Comparable { 28 public let rawValue: String 29 30 public init(_ value: String) throws { 31 self.rawValue = try RadrootsBackgroundTaskValidation.normalizedIdentifier(value) 32 } 33 34 public static func < (lhs: Self, rhs: Self) -> Bool { 35 lhs.rawValue < rhs.rawValue 36 } 37 } 38 39 public struct RadrootsBackgroundTaskRequest: Sendable, Equatable, Hashable { 40 public let identifier: RadrootsBackgroundTaskIdentifier 41 public let kind: RadrootsBackgroundTaskKind 42 public let earliestBeginDate: Date? 43 public let requiresNetworkConnectivity: Bool 44 public let requiresExternalPower: Bool 45 46 public init( 47 identifier: RadrootsBackgroundTaskIdentifier, 48 kind: RadrootsBackgroundTaskKind, 49 earliestBeginDate: Date? = nil, 50 requiresNetworkConnectivity: Bool = false, 51 requiresExternalPower: Bool = false 52 ) throws { 53 try RadrootsBackgroundTaskValidation.validate( 54 kind: kind, 55 earliestBeginDate: earliestBeginDate, 56 requiresNetworkConnectivity: requiresNetworkConnectivity, 57 requiresExternalPower: requiresExternalPower 58 ) 59 self.identifier = identifier 60 self.kind = kind 61 self.earliestBeginDate = earliestBeginDate 62 self.requiresNetworkConnectivity = requiresNetworkConnectivity 63 self.requiresExternalPower = requiresExternalPower 64 } 65 66 public init( 67 identifier: String, 68 kind: RadrootsBackgroundTaskKind, 69 earliestBeginDate: Date? = nil, 70 requiresNetworkConnectivity: Bool = false, 71 requiresExternalPower: Bool = false 72 ) throws { 73 try self.init( 74 identifier: RadrootsBackgroundTaskIdentifier(identifier), 75 kind: kind, 76 earliestBeginDate: earliestBeginDate, 77 requiresNetworkConnectivity: requiresNetworkConnectivity, 78 requiresExternalPower: requiresExternalPower 79 ) 80 } 81 } 82 83 public struct RadrootsBackgroundTaskSnapshot: Sendable, Equatable, Hashable { 84 public let identifier: RadrootsBackgroundTaskIdentifier 85 public let kind: RadrootsBackgroundTaskKind 86 public let earliestBeginDate: Date? 87 public let submittedAt: Date 88 public let requiresNetworkConnectivity: Bool 89 public let requiresExternalPower: Bool 90 91 public init( 92 identifier: RadrootsBackgroundTaskIdentifier, 93 kind: RadrootsBackgroundTaskKind, 94 earliestBeginDate: Date? = nil, 95 submittedAt: Date = Date(), 96 requiresNetworkConnectivity: Bool = false, 97 requiresExternalPower: Bool = false 98 ) throws { 99 try RadrootsBackgroundTaskValidation.validate( 100 kind: kind, 101 earliestBeginDate: earliestBeginDate, 102 requiresNetworkConnectivity: requiresNetworkConnectivity, 103 requiresExternalPower: requiresExternalPower 104 ) 105 guard submittedAt.timeIntervalSinceReferenceDate.isFinite else { 106 throw RadrootsBackgroundTaskError.invalidRequest("background task submitted date must be finite") 107 } 108 self.identifier = identifier 109 self.kind = kind 110 self.earliestBeginDate = earliestBeginDate 111 self.submittedAt = submittedAt 112 self.requiresNetworkConnectivity = requiresNetworkConnectivity 113 self.requiresExternalPower = requiresExternalPower 114 } 115 116 public init(request: RadrootsBackgroundTaskRequest, submittedAt: Date = Date()) throws { 117 try self.init( 118 identifier: request.identifier, 119 kind: request.kind, 120 earliestBeginDate: request.earliestBeginDate, 121 submittedAt: submittedAt, 122 requiresNetworkConnectivity: request.requiresNetworkConnectivity, 123 requiresExternalPower: request.requiresExternalPower 124 ) 125 } 126 } 127 128 public protocol RadrootsBackgroundTaskScheduler: Sendable { 129 func submit(_ request: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot 130 func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) async throws 131 func cancelAll() async throws 132 func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot] 133 } 134 135 public struct RadrootsUnavailableBackgroundTaskScheduler: RadrootsBackgroundTaskScheduler, Sendable { 136 private let reason: String 137 138 public init(reason: String = "background task scheduling is unavailable on this platform") { 139 let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) 140 self.reason = trimmedReason.isEmpty ? "background task scheduling is unavailable on this platform" : trimmedReason 141 } 142 143 public func submit(_ request: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot { 144 throw RadrootsBackgroundTaskError.unavailable(reason) 145 } 146 147 public func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) async throws { 148 throw RadrootsBackgroundTaskError.unavailable(reason) 149 } 150 151 public func cancelAll() async throws { 152 throw RadrootsBackgroundTaskError.unavailable(reason) 153 } 154 155 public func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot] { 156 throw RadrootsBackgroundTaskError.unavailable(reason) 157 } 158 } 159 160 public enum RadrootsBackgroundTaskValidation { 161 public static func normalizedIdentifier(_ value: String) throws -> String { 162 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 163 guard !trimmed.isEmpty else { 164 throw RadrootsBackgroundTaskError.invalidRequest("background task identifier must not be empty") 165 } 166 guard trimmed.count <= 255 else { 167 throw RadrootsBackgroundTaskError.invalidRequest("background task identifier is too long") 168 } 169 guard trimmed.range( 170 of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", 171 options: .regularExpression 172 ) != nil else { 173 throw RadrootsBackgroundTaskError.invalidRequest("background task identifier must use lowercase safe identifier characters") 174 } 175 guard !trimmed.contains("..") else { 176 throw RadrootsBackgroundTaskError.invalidRequest("background task identifier cannot contain empty path components") 177 } 178 return trimmed 179 } 180 181 public static func validate( 182 kind: RadrootsBackgroundTaskKind, 183 earliestBeginDate: Date?, 184 requiresNetworkConnectivity: Bool, 185 requiresExternalPower: Bool 186 ) throws { 187 if let earliestBeginDate { 188 guard earliestBeginDate.timeIntervalSinceReferenceDate.isFinite else { 189 throw RadrootsBackgroundTaskError.invalidRequest("background task earliest begin date must be finite") 190 } 191 } 192 guard kind == .processing || (!requiresNetworkConnectivity && !requiresExternalPower) else { 193 throw RadrootsBackgroundTaskError.invalidRequest("app refresh tasks cannot require network connectivity or external power") 194 } 195 } 196 }