diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 985792c..0a86036 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -15,7 +15,7 @@ jobs: - name: Check Swift version run: swift --version - name: Build with strict concurrency checks - run: swift build + run: swift build -Xswiftc -strict-concurrency=complete lint: runs-on: macos-26 diff --git a/Kumo.xcodeproj/project.pbxproj b/Kumo.xcodeproj/project.pbxproj index bced206..e0db03e 100644 --- a/Kumo.xcodeproj/project.pbxproj +++ b/Kumo.xcodeproj/project.pbxproj @@ -56,13 +56,8 @@ 94C185EB22D8E01100CD66DC /* ThrowingDataRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C185EA22D8E01100CD66DC /* ThrowingDataRepresentable.swift */; }; 94C185ED22D8E13400CD66DC /* UIImage+DataRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C185EC22D8E13400CD66DC /* UIImage+DataRepresentable.swift */; }; 94C185EF22D8E19400CD66DC /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C185EE22D8E19400CD66DC /* Errors.swift */; }; - 94C3BD95219B0F8100B4A3E2 /* Progress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C3BD94219B0F8100B4A3E2 /* Progress.swift */; }; - 94F2CDCD222A3602006D9C36 /* Service+Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F2CDC7222A3602006D9C36 /* Service+Download.swift */; }; - 94F2CDCF222A3602006D9C36 /* Service+SideEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F2CDC9222A3602006D9C36 /* Service+SideEffects.swift */; }; - 94F2CDD0222A3602006D9C36 /* Service+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F2CDCA222A3602006D9C36 /* Service+Upload.swift */; }; B55A1178233E5D92006EAB34 /* Unkeyed.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55A1177233E5D92006EAB34 /* Unkeyed.swift */; }; B58694D12322B3CC006C20EE /* Epic.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58694D02322B3CC006C20EE /* Epic.swift */; }; - B58782DC2538F9D700A62D73 /* AnyPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58782DB2538F9D700A62D73 /* AnyPublisher.swift */; }; B59A570924A7084F00EA68FF /* AnyCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59A570824A7084F00EA68FF /* AnyCancellable.swift */; }; B5B657D424A6444F00776C23 /* KumoNamespaceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B657D324A6444F00776C23 /* KumoNamespaceProxy.swift */; }; D0F9B5A62441566800038580 /* KumoCoding.h in Headers */ = {isa = PBXBuildFile; fileRef = D0F9B5A42441566800038580 /* KumoCoding.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -187,13 +182,8 @@ 94C185EA22D8E01100CD66DC /* ThrowingDataRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThrowingDataRepresentable.swift; sourceTree = ""; }; 94C185EC22D8E13400CD66DC /* UIImage+DataRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+DataRepresentable.swift"; sourceTree = ""; }; 94C185EE22D8E19400CD66DC /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; - 94C3BD94219B0F8100B4A3E2 /* Progress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Progress.swift; sourceTree = ""; }; - 94F2CDC7222A3602006D9C36 /* Service+Download.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Service+Download.swift"; sourceTree = ""; }; - 94F2CDC9222A3602006D9C36 /* Service+SideEffects.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Service+SideEffects.swift"; sourceTree = ""; }; - 94F2CDCA222A3602006D9C36 /* Service+Upload.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Service+Upload.swift"; sourceTree = ""; }; B55A1177233E5D92006EAB34 /* Unkeyed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Unkeyed.swift; sourceTree = ""; }; B58694D02322B3CC006C20EE /* Epic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Epic.swift; sourceTree = ""; }; - B58782DB2538F9D700A62D73 /* AnyPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyPublisher.swift; sourceTree = ""; }; B59A570824A7084F00EA68FF /* AnyCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCancellable.swift; sourceTree = ""; }; B5B657D324A6444F00776C23 /* KumoNamespaceProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KumoNamespaceProxy.swift; sourceTree = ""; }; D0F9B5A22441566800038580 /* KumoCoding.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = KumoCoding.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -313,9 +303,7 @@ children = ( B59A570824A7084F00EA68FF /* AnyCancellable.swift */, 945421752187889200932CAF /* Copying.swift */, - 94C3BD94219B0F8100B4A3E2 /* Progress.swift */, 945421732187883B00932CAF /* URLSession.swift */, - B58782DB2538F9D700A62D73 /* AnyPublisher.swift */, ); path = Extensions; sourceTree = ""; @@ -526,9 +514,6 @@ 94F2CDB92229F852006D9C36 /* Functions */ = { isa = PBXGroup; children = ( - 94F2CDC7222A3602006D9C36 /* Service+Download.swift */, - 94F2CDC9222A3602006D9C36 /* Service+SideEffects.swift */, - 94F2CDCA222A3602006D9C36 /* Service+Upload.swift */, ); path = Functions; sourceTree = ""; @@ -782,9 +767,7 @@ 945421742187883B00932CAF /* URLSession.swift in Sources */, 94C185EB22D8E01100CD66DC /* ThrowingDataRepresentable.swift in Sources */, 94C185ED22D8E13400CD66DC /* UIImage+DataRepresentable.swift in Sources */, - 94C3BD95219B0F8100B4A3E2 /* Progress.swift in Sources */, 949AD294218BD7D500808C79 /* ResponseError.swift in Sources */, - 94F2CDCD222A3602006D9C36 /* Service+Download.swift in Sources */, 9430733F22D764AD00AEAC8D /* HTTP.swift in Sources */, 94690FC222CCE16B002C37BF /* URLRequest+HTTPHeader.swift in Sources */, 949AD296218BD80E00808C79 /* MultipartForm.swift in Sources */, @@ -793,12 +776,10 @@ EA646FBC26CE930200482D6B /* Kumo.docc in Sources */, 946F43AD22DCEA7500CE9EC9 /* DataConvertible.swift in Sources */, B5B657D424A6444F00776C23 /* KumoNamespaceProxy.swift in Sources */, - 94F2CDCF222A3602006D9C36 /* Service+SideEffects.swift in Sources */, 94690FBD22CBA823002C37BF /* Authorization.swift in Sources */, 94690FBF22CCE097002C37BF /* URLSessionConfiguration+HTTPHeader.swift in Sources */, 949AD292218BD75500808C79 /* UploadError.swift in Sources */, 946F43AB22DCEA5900CE9EC9 /* DataRepresentable.swift in Sources */, - 94F2CDD0222A3602006D9C36 /* Service+Upload.swift in Sources */, 945D4114217F6222008ACFD0 /* Service.swift in Sources */, 946F43AF22DCEA9200CE9EC9 /* FailableDataConvertible.swift in Sources */, 94C185E922D8DEF200CD66DC /* FailableDataRepresentable.swift in Sources */, @@ -816,7 +797,6 @@ B59A570924A7084F00EA68FF /* AnyCancellable.swift in Sources */, 94C185EF22D8E19400CD66DC /* Errors.swift in Sources */, 94A4E67622DCF3540033B480 /* Storage.swift in Sources */, - B58782DC2538F9D700A62D73 /* AnyPublisher.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -949,7 +929,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -1007,7 +987,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -1032,7 +1012,7 @@ FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Sources/Kumo/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1062,7 +1042,7 @@ FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Sources/Kumo/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1085,7 +1065,7 @@ DEVELOPMENT_TEAM = 75Y586SA36; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Tests/KumoTests/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1106,7 +1086,7 @@ DEVELOPMENT_TEAM = 75Y586SA36; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Tests/KumoTests/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1130,7 +1110,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "${SRCROOT}/Sources/KumoCoding/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1159,7 +1139,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "${SRCROOT}/Sources/KumoCoding/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Package.swift b/Package.swift index 2deb98e..62e23f1 100644 --- a/Package.swift +++ b/Package.swift @@ -4,9 +4,9 @@ import PackageDescription let package = Package( name: "Kumo", platforms: [ - .iOS(.v26), - .tvOS(.v26), - .macOS(.v26), + .iOS(.v18), + .tvOS(.v18), + .macOS(.v15), ], products: [ .library(name: "Kumo", targets: ["Kumo"]), diff --git a/Sources/Kumo/ApplicationLayer.swift b/Sources/Kumo/ApplicationLayer.swift index 7f8a6d4..b460e36 100644 --- a/Sources/Kumo/ApplicationLayer.swift +++ b/Sources/Kumo/ApplicationLayer.swift @@ -1,6 +1,6 @@ -import Combine +@preconcurrency import Combine import Foundation -import SystemConfiguration +import Network /// The network connectivity status. public enum NetworkConnectivity { @@ -24,11 +24,11 @@ public enum NetworkConnectivity { /// services and exposes a publisher ``networkConnectivity`` to monitor network /// connectivity. open class ApplicationLayer { - - private var commonHeaders = [String: String]() + private let services: [ServiceKey: Service] private let networkConnectivitySubject: CurrentValueSubject = .init(.unknown) + private let pathMonitor = NWPathMonitor() /// A publisher that updates with the current network connectivity status /// for the device. @@ -40,18 +40,29 @@ open class ApplicationLayer { /// pairs. public init(with services: [ServiceKey: Service] = [:]) { self.services = services - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: MemoryLayout.size) { - SCNetworkReachabilityCreateWithAddress(nil, $0) + + let subject = networkConnectivitySubject + pathMonitor.pathUpdateHandler = { path in + let connectivity: NetworkConnectivity + switch path.status { + case .satisfied: + #if os(iOS) + connectivity = path.isExpensive ? .wwan : .internet + #else + connectivity = .internet + #endif + case .unsatisfied, .requiresConnection: + connectivity = .notConnected + @unknown default: + connectivity = .unknown } + subject.send(connectivity) } - .map { [unowned self] in self.publishReachability($0) }? - .sink(receiveValue: { [unowned self] in self.networkConnectivitySubject.send($0) }) - .withLifetime(of: self) + pathMonitor.start(queue: DispatchQueue(label: "DuetHealth.Kumo.networkMonitor")) + } + + deinit { + pathMonitor.cancel() } /// Retrieves the service for a given `key`. @@ -61,48 +72,4 @@ open class ApplicationLayer { return services[key]! } - private func publishReachability(_ reachability: SCNetworkReachability) -> AnyPublisher { - AnyPublisher.create { subscriber in - var context = SCNetworkReachabilityContext(version: 0, info: nil, retain: nil, release: nil, copyDescription: nil) - context.info = Unmanaged.passRetained(AnyObserverReference(subscriber)).toOpaque() - SCNetworkReachabilitySetCallback(reachability, { _, flags, info in - guard let observer = info.map({ Unmanaged>.fromOpaque($0).takeUnretainedValue() }) else { return } - if flags.isReachable { - #if os(iOS) - observer.base.onNext(flags.contains(.isWWAN) ? .wwan : .internet) - #else - observer.base.onNext(.internet) - #endif - } else { - observer.base.onNext(.notConnected) - } - }, &context) - SCNetworkReachabilitySetDispatchQueue(reachability, DispatchQueue.main) - return AnyCancellable() { - SCNetworkReachabilitySetCallback(reachability, nil, nil) - SCNetworkReachabilitySetDispatchQueue(reachability, nil) - } - } - } - -} - -private class AnyObserverReference where Failure: Error { - - let base: AnyObserver - - init(_ base: AnyObserver) { - self.base = base - } - -} - -private extension SCNetworkReachabilityFlags { - - var isReachable: Bool { - let canConnectAutomatically = contains(.connectionOnDemand) || contains(.connectionOnTraffic) && !contains(.interventionRequired) - return contains(.reachable) - && (!contains(.connectionRequired) || canConnectAutomatically) - } - } diff --git a/Sources/Kumo/Blobs/BlobCache.swift b/Sources/Kumo/Blobs/BlobCache.swift index 3cbcf66..495c3f1 100644 --- a/Sources/Kumo/Blobs/BlobCache.swift +++ b/Sources/Kumo/Blobs/BlobCache.swift @@ -1,4 +1,3 @@ -import Combine import Foundation #if canImport(UIKit) @@ -95,10 +94,10 @@ public class BlobCache { /// exists and has not expired it will be returned instead of re-fetching /// the response. /// - Parameter url: The URL for the blob resource to be located. - /// - Returns: A publisher for an object representing the data for the - /// blob resource at the `url`. - public func fetch(from url: URL) -> AnyPublisher where D._RepresentationArguments == Void, D._ConversionArguments == Void { - return fetch(from: url, convertWith: (), representWith: ()) + /// - Returns: An object representing the data for the blob resource at + /// the `url`. + public func fetch(from url: URL) async throws -> D where D._RepresentationArguments == Void, D._ConversionArguments == Void { + try await fetch(from: url, convertWith: (), representWith: ()) } /// Retrieves the blob resource from the given `url`. If a cached response @@ -108,10 +107,10 @@ public class BlobCache { /// - url: The URL for the blob resource to be located. /// - representationArguments: Arguments to be used to construct /// the representing object. - /// - Returns: A publisher for an object representing the data for the - /// blob resource at the `url`. - public func fetch(from url: URL, representWith representationArguments: D._RepresentationArguments) -> AnyPublisher where D._ConversionArguments == Void { - return fetch(from: url, convertWith: (), representWith: representationArguments) + /// - Returns: An object representing the data for the blob resource at + /// the `url`. + public func fetch(from url: URL, representWith representationArguments: D._RepresentationArguments) async throws -> D where D._ConversionArguments == Void { + try await fetch(from: url, convertWith: (), representWith: representationArguments) } /// Retrieves the blob resource from the given `url`. If a cached response @@ -121,13 +120,12 @@ public class BlobCache { /// - url: The URL for the blob resource to be located. /// - conversionArguments: Arguments to be used to convert the /// blob data. - /// - Returns: A publisher for an object representing the data for the - /// blob resource at the `url`. - public func fetch(from url: URL, convertWith conversionArguments: D._ConversionArguments) -> AnyPublisher where D._RepresentationArguments == Void { - return fetch(from: url, convertWith: conversionArguments, representWith: ()) + /// - Returns: An object representing the data for the blob resource at + /// the `url`. + public func fetch(from url: URL, convertWith conversionArguments: D._ConversionArguments) async throws -> D where D._RepresentationArguments == Void { + try await fetch(from: url, convertWith: conversionArguments, representWith: ()) } - /// Retrieves the blob resource from the given `url`. If a cached response /// exists and has not expired it will be returned instead of re-fetching /// the response. @@ -137,48 +135,17 @@ public class BlobCache { /// blob data. /// - representationArguments: Arguments to be used to construct /// the representing object. - /// - Returns: A publisher for an object representing the data for the - /// blob resource at the `url`. - public func fetch(from url: URL, convertWith conversionArguments: D._ConversionArguments, representWith representationArguments: D._RepresentationArguments) -> AnyPublisher { - let downloadTask = fetch(from: url) - .flatMap { [self] downloadPath -> AnyPublisher in - do { - if let data: D = try self.ephemeralStorage.acquire(fromPath: downloadPath, origin: url, convertWith: conversionArguments, representWith: representationArguments) { - return Just(data) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - return Empty(completeImmediately: true) - .eraseToAnyPublisher() - } catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - - return Deferred> { - Future { [self] promise in - do { - promise(.success(try self.ephemeralStorage.fetch(for: url, convertWith: conversionArguments, representWith: representationArguments))) - } catch { - promise(.failure(error)) - } - } + /// - Returns: An object representing the data for the blob resource at + /// the `url`. + public func fetch(from url: URL, convertWith conversionArguments: D._ConversionArguments, representWith representationArguments: D._RepresentationArguments) async throws -> D { + if let cached: D = try ephemeralStorage.fetch(for: url, convertWith: conversionArguments, representWith: representationArguments) { + return cached } - .flatMap { (data: D?) -> AnyPublisher in - if let data = data { - return Just(data) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } else { - return downloadTask - } + let downloadPath: URL = try await service.perform(HTTP.Request.download(url)) + guard let data: D = try ephemeralStorage.acquire(fromPath: downloadPath, origin: url, convertWith: conversionArguments, representWith: representationArguments) else { + throw BlobCacheError.acquisitionFailed(url) } - .eraseToAnyPublisher() - .subscribe(on: DispatchQueue.global()) - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() + return data } /// Cleans both ephemeral and persistent storage immediately. @@ -194,27 +161,9 @@ public class BlobCache { @objc private func cleanPersistentStorage() { persistentStorage.clean() } - private func fetch(from url: URL) -> AnyPublisher { - let service = self.service - return Deferred { - Future { promise in - let sendablePromise = UncheckedSendableBox(promise) - Task.detached { - do { - let downloadPath = try await service.perform(HTTP.Request.download(url)) - sendablePromise.value(.success(downloadPath)) - } catch { - sendablePromise.value(.failure(error)) - } - } - } - }.eraseToAnyPublisher() - } + } -private struct UncheckedSendableBox: @unchecked Sendable { - let value: T - init(_ value: T) { - self.value = value - } +enum BlobCacheError: Error { + case acquisitionFailed(URL) } diff --git a/Sources/Kumo/Blobs/Storage/FileSystem.swift b/Sources/Kumo/Blobs/Storage/FileSystem.swift index 1de0d0e..52e0c62 100644 --- a/Sources/Kumo/Blobs/Storage/FileSystem.swift +++ b/Sources/Kumo/Blobs/Storage/FileSystem.swift @@ -154,8 +154,8 @@ extension NSError { enum FileErrors { - nonisolated(unsafe) static var domain = NSCocoaErrorDomain - nonisolated(unsafe) static var fileExistsErrorCode = 516 + static let domain = NSCocoaErrorDomain + static let fileExistsErrorCode = 516 } diff --git a/Sources/Kumo/Blobs/Storage/InMemory.swift b/Sources/Kumo/Blobs/Storage/InMemory.swift index 0b3828f..fe6d381 100644 --- a/Sources/Kumo/Blobs/Storage/InMemory.swift +++ b/Sources/Kumo/Blobs/Storage/InMemory.swift @@ -1,6 +1,6 @@ import Foundation -class InMemory: StorageLocation { +class InMemory: StorageLocation, @unchecked Sendable { private class Reference { let key: String @@ -24,26 +24,34 @@ class InMemory: StorageLocation { weak var delegate: StoragePruningDelegate? func fetch(for url: URL, arguments _: D._RepresentationArguments) throws -> D? { - switch backingCache.object(forKey: cachePathResolver.path(for: url.absoluteString) as NSString) { - case .none: - return nil - case let .some(object) where object.value is D: - if let newExpirationDate = delegate?.newExpirationDate(given: CachedObjectParameters(referenceDate: object.referenceDate, expirationDate: object.expirationDate)) { - object.referenceDate = Date() - object.expirationDate = newExpirationDate + try queue.sync { + switch backingCache.object(forKey: cachePathResolver.path(for: url.absoluteString) as NSString) { + case .none: + return nil + case let .some(object) where object.value is D: + if let newExpirationDate = delegate?.newExpirationDate(given: CachedObjectParameters(referenceDate: object.referenceDate, expirationDate: object.expirationDate)) { + object.referenceDate = Date() + object.expirationDate = newExpirationDate + } + return object.value as? D + case let .some(object): + throw StorageAccessError.typeMismatch(expected: D.self, found: object.value) } - return object.value as? D - case let .some(object): - throw StorageAccessError.typeMismatch(expected: D.self, found: object.value) } } func write(_ object: D, from url: URL, arguments _: D._ConversionArguments) throws { + // nonisolated(unsafe) is used because D is not constrained to Sendable, + // but all conforming types (Data, Date, UIImage) are either value types + // or effectively immutable reference types. The queue serializes access + // to the cache's own state; this annotation bridges the object into the + // async closure without requiring a Sendable constraint on the public API. + nonisolated(unsafe) let value: Any = object queue.async { [weak self] in - guard let self = self else { return } + guard let self else { return } let cacheKey = self.cachePathResolver.path(for: url.absoluteString) let expirationDate = self.delegate?.newExpirationDate(given: CachedObjectParameters()) ?? Date() - self.backingCache.setObject(InMemory.Reference(key: cacheKey, value: object, expirationDate: expirationDate), forKey: cacheKey as NSString) + self.backingCache.setObject(InMemory.Reference(key: cacheKey, value: value, expirationDate: expirationDate), forKey: cacheKey as NSString) self.keys.insert(cacheKey) } } @@ -53,12 +61,14 @@ class InMemory: StorageLocation { } func contains(_ url: URL) -> Bool { - return keys.contains(cachePathResolver.path(for: url.absoluteString)) + queue.sync { + keys.contains(cachePathResolver.path(for: url.absoluteString)) + } } func removeAll() { queue.async { [weak self] in - guard let self = self else { return } + guard let self else { return } self.backingCache.removeAllObjects() self.keys.removeAll() } @@ -66,7 +76,7 @@ class InMemory: StorageLocation { func pruneExpired() { queue.async { [weak self] in - guard let self = self else { return } + guard let self else { return } self.keys.filter { guard let reference = self.backingCache.object(forKey: $0 as NSString) else { return true } return reference.expirationDate < Date().addingTimeInterval(.ulpOfOne) diff --git a/Sources/Kumo/Blobs/Storage/StorageLocation.swift b/Sources/Kumo/Blobs/Storage/StorageLocation.swift index 3e910b3..e36aa1d 100644 --- a/Sources/Kumo/Blobs/Storage/StorageLocation.swift +++ b/Sources/Kumo/Blobs/Storage/StorageLocation.swift @@ -1,7 +1,7 @@ import Foundation /// An error that occurred while attempting to access storage. -public enum StorageAccessError: Error { +public enum StorageAccessError: Error, @unchecked Sendable { /// A type mismatch occurred while trying to access storage. case typeMismatch(expected: T.Type, found: Any) diff --git a/Sources/Kumo/Blobs/Types/Errors.swift b/Sources/Kumo/Blobs/Types/Errors.swift index 4b2d70f..21b6398 100644 --- a/Sources/Kumo/Blobs/Types/Errors.swift +++ b/Sources/Kumo/Blobs/Types/Errors.swift @@ -1,9 +1,9 @@ import Foundation -enum CacheDeserializationError: Error { +enum CacheDeserializationError: Error, @unchecked Sendable { case initializationFailed(T.Type, data: Data, arguments: Any) } -enum CacheSerializationError: Error { +enum CacheSerializationError: Error, @unchecked Sendable { case dataConversionFailed(T.Type, object: T, arguments: Any) } diff --git a/Sources/Kumo/Data/FileType.swift b/Sources/Kumo/Data/FileType.swift index cb27ac7..5cbe7f1 100644 --- a/Sources/Kumo/Data/FileType.swift +++ b/Sources/Kumo/Data/FileType.swift @@ -1,18 +1,15 @@ import Foundation - -#if !os(macOS) -import MobileCoreServices -#endif +import UniformTypeIdentifiers /// A structure representing information about a file. public struct FileType: Equatable, Codable { - + enum AssociationError: Error { case noMIMEType case noUTI case noExtension } - + public static func ==(_ lhs: FileType, _ rhs: FileType) -> Bool { return lhs.fileExtension == rhs.fileExtension } @@ -23,38 +20,39 @@ public struct FileType: Equatable, Codable { /// The file's MIME type. public let mimeType: String - /// The file's uniform type identifier.; + /// The file's uniform type identifier. public let uti: String /// Creates a ``FileType`` object from the given `uti`. /// - Parameter uti: A file's uniform type identifier. public init(uti: String) throws { + let type = UTType(uti) self.uti = uti - guard let mimeType = UTTypeCopyPreferredTagWithClass(uti as CFString, kUTTagClassMIMEType) else { throw AssociationError.noMIMEType } - self.mimeType = mimeType.takeRetainedValue() as String - self.fileExtension = UTTypeCopyPreferredTagWithClass(uti as CFString, kUTTagClassFilenameExtension)?.takeRetainedValue() as String? ?? "" + guard let mimeType = type?.preferredMIMEType else { throw AssociationError.noMIMEType } + self.mimeType = mimeType + self.fileExtension = type?.preferredFilenameExtension ?? "" } /// Creates a ``FileType`` object from the given `fileExtension`. /// - Parameter fileExtension: A file's extension. public init(fileExtension: String) throws { - guard let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension as CFString, nil) else { throw AssociationError.noUTI } - self.uti = uti.takeRetainedValue() as String - guard let mimeType = UTTypeCopyPreferredTagWithClass(self.uti as CFString, kUTTagClassMIMEType) else { throw AssociationError.noMIMEType } - self.mimeType = mimeType.takeRetainedValue() as String + guard let type = UTType(filenameExtension: fileExtension) else { throw AssociationError.noUTI } + self.uti = type.identifier + guard let mimeType = type.preferredMIMEType else { throw AssociationError.noMIMEType } + self.mimeType = mimeType self.fileExtension = fileExtension } /// Creates a ``FileType`` object from the given `mimeType`. /// - Parameter mimeType: A file's MIME type. public init(mimeType: String) throws { - guard let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil) else { throw AssociationError.noUTI } - self.uti = uti.takeRetainedValue() as String + guard let type = UTType(mimeType: mimeType) else { throw AssociationError.noUTI } + self.uti = type.identifier self.mimeType = mimeType - guard let fileExtension = UTTypeCopyPreferredTagWithClass(self.uti as CFString, kUTTagClassFilenameExtension) else { throw AssociationError.noExtension } - self.fileExtension = fileExtension.takeRetainedValue() as String + guard let fileExtension = type.preferredFilenameExtension else { throw AssociationError.noExtension } + self.fileExtension = fileExtension } - + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let fileExtension = try container.decode(String.self) @@ -63,11 +61,10 @@ public struct FileType: Equatable, Codable { } self = fileType } - + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(fileExtension) } - -} +} diff --git a/Sources/Kumo/Errors/HTTPError.swift b/Sources/Kumo/Errors/HTTPError.swift index 5c890fe..aeee3d0 100644 --- a/Sources/Kumo/Errors/HTTPError.swift +++ b/Sources/Kumo/Errors/HTTPError.swift @@ -1,7 +1,7 @@ import Foundation /// An enumeration of HTTP errors. -public enum HTTPError: Error { +public enum HTTPError: Error, @unchecked Sendable { /// The URL / parameter list is invalid. case malformedURL(_ url: URL, parameters: [String: Any]) diff --git a/Sources/Kumo/Extensions/AnyCancellable.swift b/Sources/Kumo/Extensions/AnyCancellable.swift index f17b8bb..74b05fa 100644 --- a/Sources/Kumo/Extensions/AnyCancellable.swift +++ b/Sources/Kumo/Extensions/AnyCancellable.swift @@ -1,14 +1,14 @@ import Combine import Foundation -nonisolated(unsafe) private var cancellablesKey = UInt8.zero +private nonisolated(unsafe) var cancellablesKey: UInt8 = 0 extension Cancellable { func withLifetime(of object: AnyObject) { var cancellables = objc_getAssociatedObject(object, &cancellablesKey) as? [AnyCancellable] ?? [AnyCancellable]() AnyCancellable(self).store(in: &cancellables) - objc_setAssociatedObject(object, &cancellables, cancellables, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(object, &cancellablesKey, cancellables, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } diff --git a/Sources/Kumo/Extensions/AnyPublisher.swift b/Sources/Kumo/Extensions/AnyPublisher.swift deleted file mode 100644 index 638f1cc..0000000 --- a/Sources/Kumo/Extensions/AnyPublisher.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Combine -import Foundation - -// https://stackoverflow.com/a/61035663/104527 -struct AnyObserver { - let onNext: ((Output) -> Void) - let onError: ((Failure) -> Void) - let onComplete: (() -> Void) -} - -struct Disposable { - let dispose: () -> Void -} - -extension AnyPublisher { - static func create(subscribe: @escaping (AnyObserver) -> AnyCancellable) -> Self { - let subject = PassthroughSubject() - var cancellable: AnyCancellable? - return subject - .handleEvents(receiveSubscription: { subscription in - cancellable = subscribe(AnyObserver( - onNext: { output in subject.send(output) }, - onError: { failure in subject.send(completion: .failure(failure)) }, - onComplete: { subject.send(completion: .finished) } - )) - }, receiveCancel: { cancellable?.cancel() }) - .eraseToAnyPublisher() - } -} diff --git a/Sources/Kumo/Extensions/Progress.swift b/Sources/Kumo/Extensions/Progress.swift deleted file mode 100644 index 15825e3..0000000 --- a/Sources/Kumo/Extensions/Progress.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Combine -import Foundation - -extension _KumoNamespace where Base: Progress { - var fractionComplete: AnyPublisher { - base.publisher(for: \.fractionCompleted) - .eraseToAnyPublisher() - } -} diff --git a/Sources/Kumo/Extensions/URLSession.swift b/Sources/Kumo/Extensions/URLSession.swift index 77281a1..e81ac1d 100644 --- a/Sources/Kumo/Extensions/URLSession.swift +++ b/Sources/Kumo/Extensions/URLSession.swift @@ -6,27 +6,10 @@ protocol InvalidationProtocol { final class URLSessionInvalidationDelegate: NSObject, URLSessionDelegate, InvalidationProtocol, @unchecked Sendable { fileprivate var invalidations = [URLSession: (URLSession, Error?) -> Void]() + private let queue = DispatchQueue(label: "DuetHealth.Kumo.invalidations") func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { - invalidations[session]?(session, error) - invalidations[session] = nil - } - - override func conforms(to aProtocol: Protocol) -> Bool { - return protocol_isEqual(aProtocol, URLSessionTaskDelegate.self) || super.conforms(to: aProtocol) - } - - func invalidate(session: URLSession, onInvalidation: @escaping (URLSession, Error?) -> Void) { - invalidations[session] = onInvalidation - } -} - -final class URLSessionThreadSafeInvalidationDelegate: NSObject, URLSessionDelegate, InvalidationProtocol, @unchecked Sendable { - fileprivate var invalidations = [URLSession: (URLSession, Error?) -> Void]() - var invalidationQueue = DispatchQueue(label: "DuetHealth.Kumo.invalidations") - - func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { - invalidationQueue.sync { + queue.sync { invalidations[session]?(session, error) invalidations[session] = nil } @@ -37,7 +20,7 @@ final class URLSessionThreadSafeInvalidationDelegate: NSObject, URLSessionDelega } func invalidate(session: URLSession, onInvalidation: @escaping (URLSession, Error?) -> Void) { - invalidationQueue.sync { + queue.sync { invalidations[session] = onInvalidation } } diff --git a/Sources/Kumo/HTTP/HTTPRequest.swift b/Sources/Kumo/HTTP/HTTPRequest.swift index e2356aa..ac07941 100644 --- a/Sources/Kumo/HTTP/HTTPRequest.swift +++ b/Sources/Kumo/HTTP/HTTPRequest.swift @@ -4,13 +4,13 @@ import Foundation import KumoCoding #endif -public protocol _RequestMethod { } -public protocol _RequestResource { } -public protocol _RequestBody { } -public protocol _RequestParameters { } -public protocol _ResponseNestedKey { } -public protocol _RequestDispositionName { } -public protocol _UploadProgress { } +public protocol _RequestMethod: Sendable { } +public protocol _RequestResource: Sendable { } +public protocol _RequestBody: Sendable { } +public protocol _RequestParameters: Sendable { } +public protocol _ResponseNestedKey: Sendable { } +public protocol _RequestDispositionName: Sendable { } +public protocol _UploadProgress: Sendable { } public typealias _RequestOption = _RequestMethod & _RequestResource & _RequestBody & _RequestParameters & _ResponseNestedKey & _RequestDispositionName & _UploadProgress public enum _NoOption: _RequestOption { } public enum _HasOption: _RequestOption { } diff --git a/Sources/Kumo/HTTP/HTTPResponseStatus.swift b/Sources/Kumo/HTTP/HTTPResponseStatus.swift index e673137..f967d82 100644 --- a/Sources/Kumo/HTTP/HTTPResponseStatus.swift +++ b/Sources/Kumo/HTTP/HTTPResponseStatus.swift @@ -4,7 +4,7 @@ public extension HTTP { /// The HTTP response status for a given request. /// - seealso: [Response Status Codes](https://httpwg.org/specs/rfc7231.html#status.codes) - enum ResponseStatus: Int { + enum ResponseStatus: Int, Sendable { case unknown = -1337 diff --git a/Sources/Kumo/Logger/KumoLogger.swift b/Sources/Kumo/Logger/KumoLogger.swift index c221ce3..cfa3bcb 100644 --- a/Sources/Kumo/Logger/KumoLogger.swift +++ b/Sources/Kumo/Logger/KumoLogger.swift @@ -1,7 +1,6 @@ -import Combine import Foundation -public protocol KumoLogger { +public protocol KumoLogger: Sendable { func log(message: String, error: Error?) diff --git a/Sources/Kumo/Services/Functions/Service+Download.swift b/Sources/Kumo/Services/Functions/Service+Download.swift deleted file mode 100644 index 212e9fa..0000000 --- a/Sources/Kumo/Services/Functions/Service+Download.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Combine -import Foundation - -public extension Service { - - /// Downloads the resource located at the passed in `endpoint` with the - /// given URL `parameters`. - /// - Parameters: - /// - endpoint: The path extension corresponding to the endpoint. - /// - parameters: A dictionary of parameters to be used in the request - /// URL query. - /// - Returns: An [`AnyPublisher`](https://developer.apple.com/documentation/combine/anypublisher) - /// which publishes a URL to the downloaded file upon success. - @available(*, deprecated, message: "Construct a request with HTTP.Request.download(_:) and use Service/perform(_:) instead.") - func download(_ endpoint: String, parameters: [String: Any] = [:]) async throws -> URL { - try await perform(HTTP.Request.download(endpoint).parameters(parameters)) - } - -} diff --git a/Sources/Kumo/Services/Functions/Service+SideEffects.swift b/Sources/Kumo/Services/Functions/Service+SideEffects.swift deleted file mode 100644 index 4895c39..0000000 --- a/Sources/Kumo/Services/Functions/Service+SideEffects.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Combine -import Foundation - -public extension Service { - - /// Defines a scope for requests that do not require verification of - /// fulfillment. - struct SideEffectScope { - - let base: Service - - init(_ base: Service) { - self.base = base - } - - /// Perform the given request and ignore the result. Useful for "fire - /// and forget" requests where failure is an okay option. The request is - /// tied to the lifecycle of the ``Service`` performing the work and - /// will be cancelled if the ``Service`` is deallocated. - /// - Parameters: - /// - request: the request to be performed. - public func perform(_ request: HTTP._Request) async throws -> Void where HTTP._Request: Sendable { - let result: Void = try await base.perform(request) - return result - } - - } - - /// Provides a convenient way for performing requests which are side - /// effects; that is, requests for which observing the response is - /// unnecessary. - var unobserved: SideEffectScope { - return SideEffectScope(self) - } - -} - diff --git a/Sources/Kumo/Services/Functions/Service+Upload.swift b/Sources/Kumo/Services/Functions/Service+Upload.swift deleted file mode 100644 index 79fd159..0000000 --- a/Sources/Kumo/Services/Functions/Service+Upload.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Combine -import Foundation - -public extension Service { - - /// Uploads to an endpoint the provided file. The file is uploaded as form data - /// under the supplied key. - /// - /// - Parameters: - /// - endpoint: The path extension corresponding to the endpoint. - /// - file: The URL of the file to upload. - /// - key: The name of form part under which to embed the file's data. - /// - Returns: An [`AnyPublisher`](https://developer.apple.com/documentation/combine/anypublisher) - /// which publishes upon success. - @available(*, deprecated, message: "Construct an HTTP.Request with `.upload(_:)` and use `perform` instead.") - func upload(_ endpoint: String, file: URL, under key: String) async throws -> Response? { - try await perform(HTTP.Request.upload(endpoint).file(file).keyed(under: key)) - } - - /// Uploads to an endpoint the provided file. The file is uploaded as form data - /// under the supplied key. - /// - /// - Parameters: - /// - endpoint: The path extension corresponding to the endpoint. - /// - parameters: A dictionary of parameters to be used in the request - /// URL query. - /// - file: The URL of the file to upload - /// - key: The name of form part under which to embed the file's data - /// - Returns: An [`AnyPublisher`](https://developer.apple.com/documentation/combine/anypublisher) - /// which publishes a single empty element upon success. - @available(*, deprecated, message: "Construct an HTTP.Request with `.upload(_:)` and use `perform` instead.") - func upload(_ endpoint: String, parameters: [String: Any] = [:], file: URL, under key: String) async throws { - try await perform(HTTP.Request.upload(endpoint).parameters(parameters).file(file).keyed(under: key)) - } - - /// Uploads to an endpoint the provided file. The file is uploaded as form data - /// under the supplied key. - /// - /// - Parameters: - /// - endpoint: The path extension corresponding to the endpoint. - /// - parameters: A dictionary of parameters to be used in the request - /// URL query. - /// - file: The URL of the file to upload. - /// - key: The name of form part under which to embed the file's data. - /// - Returns: An [`AnyPublisher`](https://developer.apple.com/documentation/combine/anypublisher) - /// which publishes the progress of the upload. - @available(*, deprecated, message: "Construct an HTTP.Request with `.upload(_:)` and use `perform` with `.progress()` instead.") - func uploads(_ endpoint: String, parameters: [String: Any] = [:], file: URL, under key: String) async throws -> Double { - try await perform(HTTP.Request.upload(endpoint).parameters(parameters).file(file).keyed(under: key).progress()) - } - -} diff --git a/Sources/Kumo/Services/Service.swift b/Sources/Kumo/Services/Service.swift index 80d211f..5938e9b 100644 --- a/Sources/Kumo/Services/Service.swift +++ b/Sources/Kumo/Services/Service.swift @@ -1,4 +1,3 @@ -import Combine import Foundation #if canImport(KumoCoding) @@ -39,11 +38,6 @@ public actor Service { /// The base URL for all requests. public let baseURL: URL? - - - /// Key to enable AB testing invalidation - nonisolated(unsafe) public static var isSafeInvalidationEnabled = false - /// The type of error returned by the server. When a response returns an /// error status code, the service will attempt to decode the body of the /// response as this type. @@ -97,9 +91,6 @@ public actor Service { private var delegate: URLSessionDelegate = URLSessionInvalidationDelegate() - private let invalidationQueue = DispatchQueue(label: "DuetHealth.session.synchronization") - private let invalidationSemaphore = DispatchSemaphore(value: 1) - var session: URLSession { _session } @@ -127,9 +118,6 @@ public actor Service { } let sessionConfiguration = runsInBackground ? URLSessionConfiguration.background(withIdentifier: baseURL?.absoluteString ?? UUID().uuidString) : .default configuration?(sessionConfiguration) - if Service.isSafeInvalidationEnabled { - delegate = URLSessionThreadSafeInvalidationDelegate() - } var queue: OperationQueue? if let count = maxConcurrentOperationCount { queue = OperationQueue() @@ -169,29 +157,24 @@ public actor Service { session.configuration.headers.set(value: String(describing: value), for: header) } - /// Provides a way to reconfigure the URLSessionConfiguration that powers - /// the Service. - public func reconfigure(applying changes: @escaping @Sendable (URLSessionConfiguration) -> Void) { - _session.finishTasksAndInvalidate { [unowned self] session, _ in - let newConfiguration: URLSessionConfiguration = session.configuration.copy() - changes(newConfiguration) - self._session = URLSession(configuration: newConfiguration, delegate: self.delegate, delegateQueue: nil) - } - } - - /// Provides a way to asynchronously reconfigure the - /// [`URLSessionConfiguration`](https://developer.apple.com/documentation/foundation/urlsessionconfiguration) - /// that powers the Service. Prefer this over ``reconfigure(applyingd:)`` - /// when making a request that will modify the session configuration based - /// on the result of the request, e.g.: upon logging in and receiving a - /// token that will be added to subsequent headers. - public func reconfiguring(applying changes: @escaping @Sendable (URLSessionConfiguration) -> Void, completion: @escaping @Sendable () -> ()) { - self._session.finishTasksAndInvalidate { [unowned self] session, _ in - let newConfiguration: URLSessionConfiguration = session.configuration.copy() - changes(newConfiguration) - self._session = URLSession(configuration: newConfiguration, delegate: self.delegate, delegateQueue: nil) - completion() + /// Reconfigures the URLSessionConfiguration that powers the Service. + /// Finishes outstanding tasks, invalidates the current session, and + /// creates a new session with the modified configuration. + public func reconfigure(applying changes: @escaping @Sendable (URLSessionConfiguration) -> Void) async { + let oldSession = _session + let delegate = self.delegate + let newSession = await withCheckedContinuation { continuation in + oldSession.finishTasksAndInvalidate { session, _ in + let newConfiguration: URLSessionConfiguration = session.configuration.copy() + changes(newConfiguration) + continuation.resume(returning: URLSession( + configuration: newConfiguration, + delegate: delegate, + delegateQueue: nil + )) + } } + _session = newSession } func createRequest(method: HTTP.Method, endpoint: String, queryParameters: [String: Any] = [:], body: [String: Any]? = nil) throws -> URLRequest { diff --git a/Tests/KumoTests/Fixtures/Blobs/BlobCacheTests.swift b/Tests/KumoTests/Fixtures/Blobs/BlobCacheTests.swift index 675ae09..bb300ef 100644 --- a/Tests/KumoTests/Fixtures/Blobs/BlobCacheTests.swift +++ b/Tests/KumoTests/Fixtures/Blobs/BlobCacheTests.swift @@ -1,4 +1,3 @@ -import Combine import Foundation @testable import Kumo import XCTest @@ -6,49 +5,24 @@ import XCTest class BlobCacheTests: NetworkTest { let cache = BlobCache(baseURL: URL(string: "https://httpbin.org")!) - func testRoutineCacheCallReturnsData() { - // We clean indiscriminately in testing because we don't want artifacts to - // carry over multiple tests. We could use a mocking mechanism, but for a - // first testing pass this is a low-cost solution. + func testRoutineCacheCallReturnsData() async throws { cache.persistentStorageHeuristics.cleansIndiscriminately = true let url = URL(string: "https://httpbin.org/bytes/1024")! - var data = Data?.none XCTAssert(!cache.contains(url), "Expected the cache to be empty but contained the URL '\(url)'") - cache.fetch(from: url) - .sink(receiveCompletion: { completion in - switch completion { - case let .failure(error): - XCTFail("Fetching encountered an error: \(error)") - case .finished: - XCTAssert(self.cache.contains(url), "Expected the cache to contain URL '\(url)'") - self.cache.cleanImmediately() - XCTAssert(data != nil, "Expected data to eventually be fetched, but none was received.") - } - }, receiveValue: { (result: Data) in - data = result - }) - .withLifetime(of: self) + let data: Data = try await cache.fetch(from: url) + XCTAssert(cache.contains(url), "Expected the cache to contain URL '\(url)'") + XCTAssert(!data.isEmpty, "Expected data to eventually be fetched, but none was received.") + cache.cleanImmediately() } - func testSubsequentCacheCallReturnsData() { + func testSubsequentCacheCallReturnsData() async throws { cache.persistentStorageHeuristics.cleansIndiscriminately = true let url = URL(string: "https://httpbin.org/bytes/1024")! - var data = Data?.none XCTAssert(!cache.contains(url), "Expected the cache to be empty but contained the URL '\(url)'") - cache.fetch(from: url) - .flatMap { (_: Data) in self.cache.fetch(from: url) } - .sink(receiveCompletion: { completion in - switch completion { - case .failure(let error): - XCTFail("Fetching encountered an error: \(error)") - case .finished: - XCTAssert(self.cache.contains(url), "Expected the cache to contain URL '\(url)'") - self.cache.cleanImmediately() - XCTAssert(data != nil, "Expected data to eventually be fetched, but none was received.") - } - }, receiveValue: { (result: Data) in - data = result - }) - .withLifetime(of: self) + let _: Data = try await cache.fetch(from: url) + let data: Data = try await cache.fetch(from: url) + XCTAssert(cache.contains(url), "Expected the cache to contain URL '\(url)'") + XCTAssert(!data.isEmpty, "Expected data to eventually be fetched, but none was received.") + cache.cleanImmediately() } } diff --git a/Tests/KumoTests/Fixtures/Common/NetworkTest.swift b/Tests/KumoTests/Fixtures/Common/NetworkTest.swift index c9e0534..41ae829 100644 --- a/Tests/KumoTests/Fixtures/Common/NetworkTest.swift +++ b/Tests/KumoTests/Fixtures/Common/NetworkTest.swift @@ -1,4 +1,4 @@ -import Combine +@preconcurrency import Combine import Foundation @testable import Kumo import XCTest @@ -11,7 +11,7 @@ class NetworkTest: XCTestCase { return (actual: base, expected: base.mapValues(String.init(describing:))) }() - func successfulTest(of observable: AnyPublisher, file: StaticString = #file, line: UInt = #line, function: String = #function) -> (_ description: String) -> ((_ successCondition: @escaping (T) -> Bool) -> Void) { + func successfulTest(of observable: AnyPublisher, file: StaticString = #filePath, line: UInt = #line, function: String = #function) -> (_ description: String) -> ((_ successCondition: @escaping (T) -> Bool) -> Void) { return { description in { successCondition in var emissions = [T]() @@ -35,7 +35,7 @@ class NetworkTest: XCTestCase { } } - func erroringTest(of observable: AnyPublisher, file: StaticString = #file, line: UInt = #line, function: String = #function) -> (_ description: String) -> ((_ successCondition: @escaping (Error) -> Bool) -> Void) { + func erroringTest(of observable: AnyPublisher, file: StaticString = #filePath, line: UInt = #line, function: String = #function) -> (_ description: String) -> ((_ successCondition: @escaping (Error) -> Bool) -> Void) { return { description in { successCondition in let expect = self.expectation(description: description) @@ -58,34 +58,43 @@ class NetworkTest: XCTestCase { } } - func perform(_ request: HTTP._Request) -> AnyPublisher { - Deferred> { - Future { [self] promise in + func perform(_ request: HTTP._Request) -> AnyPublisher { + let service = self.service + return Deferred> { + Future { promise in + let box = SendableBox(promise) Task { do { let result: T = try await service.perform(request) - promise(.success(result)) - } catch let error { - promise(.failure(error)) + box.value(.success(result)) + } catch { + box.value(.failure(error)) } } }.eraseToAnyPublisher() }.eraseToAnyPublisher() } - + func perform(_ request: HTTP ._Request) -> AnyPublisher { - Deferred> { - Future { [self] promise in + let service = self.service + return Deferred> { + Future { promise in + let box = SendableBox(promise) Task { do { - let result: Void = try await service.perform(request) - promise(.success(result)) - } catch let error { - promise(.failure(error)) + try await service.perform(request) as Void + box.value(.success(())) + } catch { + box.value(.failure(error)) } } }.eraseToAnyPublisher() }.eraseToAnyPublisher() } } + +private struct SendableBox: @unchecked Sendable { + let value: T + init(_ value: T) { self.value = value } +} diff --git a/Tests/KumoTests/Fixtures/Common/TestLogger.swift b/Tests/KumoTests/Fixtures/Common/TestLogger.swift index f432107..6eae9f9 100644 --- a/Tests/KumoTests/Fixtures/Common/TestLogger.swift +++ b/Tests/KumoTests/Fixtures/Common/TestLogger.swift @@ -1,7 +1,7 @@ import Foundation @testable import Kumo -class TestLogger: KumoLogger { +final class TestLogger: KumoLogger { func log(message: String, error: Error?) { if error == nil { diff --git a/Tests/KumoTests/Mocks/Services/DynamicBody.swift b/Tests/KumoTests/Mocks/Services/DynamicBody.swift index 4500caa..63a1ba7 100644 --- a/Tests/KumoTests/Mocks/Services/DynamicBody.swift +++ b/Tests/KumoTests/Mocks/Services/DynamicBody.swift @@ -6,7 +6,7 @@ struct RequestBody: Codable { let integer: Int } - static let dynamicBody: [String: Any] = ["nested": ["integer": 3], "leaf": "string"] + nonisolated(unsafe) static let dynamicBody: [String: Any] = ["nested": ["integer": 3], "leaf": "string"] let nested: NestedBody let leaf: String diff --git a/Tests/KumoTests/Mocks/Services/MockResponse.swift b/Tests/KumoTests/Mocks/Services/MockResponse.swift index 301532b..b497840 100644 --- a/Tests/KumoTests/Mocks/Services/MockResponse.swift +++ b/Tests/KumoTests/Mocks/Services/MockResponse.swift @@ -1,13 +1,13 @@ import Foundation -struct MockResponse: Decodable { +struct MockResponse: Decodable, Sendable { let args: [String: String] let headers: [String: String] let origin: String let url: URL } -struct MockObjectResponse: Decodable { +struct MockObjectResponse: Decodable, Sendable { let args: [String: String] let headers: [String: String] let origin: String