diff --git a/Dependencies/Sources/DownloadManager/Download/Download.swift b/Dependencies/Sources/DownloadManager/Download/Download.swift index 60a5afc..71dd3bf 100644 --- a/Dependencies/Sources/DownloadManager/Download/Download.swift +++ b/Dependencies/Sources/DownloadManager/Download/Download.swift @@ -11,13 +11,13 @@ import Foundation /** The `Download` object describes a single file download. */ -public struct Download { +public struct Download: Sendable { public var url: URL public var id: UUID - public enum Destination : Codable, Hashable { + public enum Destination : Codable, Hashable, Sendable { /// Download to a shared buffer in memory case inMemory @@ -70,7 +70,7 @@ public struct Download { public var destination: Destination - public struct DownloadPolicy : OptionSet, Codable, Hashable { + public struct DownloadPolicy : OptionSet, Codable, Hashable, Sendable { public let rawValue: Int @@ -84,7 +84,7 @@ public struct Download { public var downloadPolicy: DownloadPolicy - public struct URLRequestConfiguration : Hashable, Codable { + public struct URLRequestConfiguration : Hashable, Codable, Sendable { public var allHTTPHeaderFields: [String : String]? diff --git a/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift b/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift index 512649e..e03900c 100644 --- a/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift +++ b/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift @@ -21,82 +21,75 @@ public struct DownloadPublisher: Publisher { DownloadPublisher.Failure == S.Failure, DownloadPublisher.Output == S.Input { - let subscription = DownloadSubscription(subscriber: subscriber, download: download, manager: manager) + let subscription = DownloadSubscription(subscriber: subscriber, download: download, coordinator: coordinator) subscriber.receive(subscription: subscription) } - init(download: Download, manager: DownloadManager) { + init(download: Download, coordinator: URLSessionCoordinator) { self.download = download - self.manager = manager + self.coordinator = coordinator } - private let manager: DownloadManager + private let coordinator: URLSessionCoordinator } @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -final class DownloadSubscription: Subscription - where SubscriberType.Input == DownloadInfo, - SubscriberType.Failure == DownloadError +final class DownloadSubscription: Subscription where SubscriberType.Input == DownloadInfo, SubscriberType.Failure == DownloadError { private var subscriber: SubscriberType? private let download: Download - private unowned let manager: DownloadManager + private unowned let coordinator: URLSessionCoordinator - init(subscriber: SubscriberType, download: Download, manager: DownloadManager) { + init(subscriber: SubscriberType, download: Download, coordinator: URLSessionCoordinator) { self.subscriber = subscriber self.download = download - self.manager = manager + self.coordinator = coordinator } func request(_ demand: Subscribers.Demand) { guard demand > 0 else { return } log_debug(self, #function, "download.id = \(download.id), download.url = \(self.download.url)", detail: log_detailed) + nonisolated(unsafe) let subscriber = subscriber + let download = download + let type = Self.self - manager.coordinator.startDownload(download, + coordinator.startDownload(download, receiveResponse: { _ in }, receiveData: { _, _ in }, - reportProgress: { [weak self] _, progress in - guard let self = self else { - return - } - - let _ = self.subscriber?.receive(.progress(progress)) + reportProgress: { _, progress in + let _ = subscriber?.receive(.progress(progress)) }, - completion: { [weak self] _, result in - guard let self = self else { - return - } - + completion: { _, result in switch result { case .success(let downloadResult): switch downloadResult { case .data(let data): - log_debug(self, #function, "download.id = \(self.download.id), download.url = \(self.download.url), downloaded \(data.count) bytes", detail: log_detailed) + log_debug(type, #function, "download.id = \(download.id), download.url = \(download.url), downloaded \(data.count) bytes", detail: log_detailed) case .file(let path): - log_debug(self, #function, "download.id = \(self.download.id), download.url = \(self.download.url), downloaded file to \(path)", detail: log_detailed) + log_debug(type, #function, "download.id = \(download.id), download.url = \(download.url), downloaded file to \(path)", detail: log_detailed) } - let _ = self.subscriber?.receive(.completion(downloadResult)) - self.subscriber?.receive(completion: .finished) + let _ = subscriber?.receive(.completion(downloadResult)) + subscriber?.receive(completion: .finished) case .failure(let error): - log_debug(self, #function, "download.id = \(self.download.id), download.url = \(self.download.url), downloaded failed \(error)", detail: log_detailed) - self.subscriber?.receive(completion: .failure(error)) + log_debug(type, #function, "download.id = \(download.id), download.url = \(download.url), downloaded failed \(error)", detail: log_detailed) + subscriber?.receive(completion: .failure(error)) } - self.manager.reset(download: self.download) +// self.manager.reset(download: self.download) }) } func cancel() { log_debug(self, #function, "download.id = \(download.id), download.url = \(self.download.url)", detail: log_detailed) - manager.coordinator.cancelDownload(download) - manager.reset(download: download) + coordinator.cancelDownload(download) +// manager.reset(download: download) } } diff --git a/Dependencies/Sources/DownloadManager/Download/DownloadTask.swift b/Dependencies/Sources/DownloadManager/Download/DownloadTask.swift index 4374de7..7e46e3c 100644 --- a/Dependencies/Sources/DownloadManager/Download/DownloadTask.swift +++ b/Dependencies/Sources/DownloadManager/Download/DownloadTask.swift @@ -9,9 +9,9 @@ import Foundation /// `DownloadTask` is a wrapper around `URLSessionTask` that accumulates received data in a memory buffer. -final class DownloadTask { +final class DownloadTask: @unchecked Sendable { - final class Observer { + final class Observer: @unchecked Sendable { private var receiveResponse: DownloadReceiveResponse? diff --git a/Dependencies/Sources/DownloadManager/Download/DownloadTypes.swift b/Dependencies/Sources/DownloadManager/Download/DownloadTypes.swift index 83f7bd5..42cc484 100644 --- a/Dependencies/Sources/DownloadManager/Download/DownloadTypes.swift +++ b/Dependencies/Sources/DownloadManager/Download/DownloadTypes.swift @@ -8,7 +8,7 @@ import Foundation -public enum DownloadResult { +public enum DownloadResult: Sendable { case data(_ data: Data) @@ -16,7 +16,7 @@ public enum DownloadResult { } -public enum DownloadInfo { +public enum DownloadInfo: Sendable { case progress(_ progress: Float?) @@ -28,7 +28,7 @@ extension DownloadResult : Hashable {} public typealias DownloadError = Error -public typealias DownloadReceiveResponse = (_ download: Download) -> Void -public typealias DownloadReceiveData = (_ download: Download, _ data: Data) -> Void -public typealias DownloadReportProgress = (_ download: Download, _ progress: Float?) -> Void -public typealias DownloadCompletion = (_ download: Download, _ result: Result) -> Void +public typealias DownloadReceiveResponse = @Sendable (_ download: Download) -> Void +public typealias DownloadReceiveData = @Sendable (_ download: Download, _ data: Data) -> Void +public typealias DownloadReportProgress = @Sendable (_ download: Download, _ progress: Float?) -> Void +public typealias DownloadCompletion = @Sendable (_ download: Download, _ result: Result) -> Void diff --git a/Dependencies/Sources/DownloadManager/DownloadManager.swift b/Dependencies/Sources/DownloadManager/DownloadManager.swift index 1bcd59b..d540d48 100644 --- a/Dependencies/Sources/DownloadManager/DownloadManager.swift +++ b/Dependencies/Sources/DownloadManager/DownloadManager.swift @@ -6,8 +6,8 @@ // import Foundation -import Combine - +@preconcurrency import Combine +import AsyncExtensions @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public final class DownloadManager { @@ -20,34 +20,231 @@ public final class DownloadManager { public typealias DownloadTaskPublisher = Publishers.Share - public func publisher(for download: Download) -> DownloadTaskPublisher { - sync { - let publisher = publishers[download] ?? DownloadPublisher(download: download, manager: self).share() - publishers[download] = publisher - - return publisher - } - } - - public func reset(download: Download) { - async { [weak self] in - guard let self = self else { - return +// public func publisher(for download: Download) -> DownloadTaskPublisher { +// sync { +// let publisher = publishers[download] ?? DownloadPublisher(download: download, manager: self).share() +// publishers[download] = publisher +// +// return publisher +// } +// } + +// public func download(for download: Download) -> AsyncStream> { +// AsyncStream { continuation in +// sync { +// let current = publishers[download] +// let publisher = current ?? DownloadPublisher(download: download, manager: self).share() +// print("current downloader task for \(download.url) - \(current), publisher \(publisher)") +// publishers[download] = publisher +// var cancelHash: Int? +// let cancellable = publisher.sink { [weak self] completion in +// switch completion { +// case .finished: +// break +// case .failure(let error): +// continuation.yield(with: .success(.failure(error))) +// } +// continuation.finish() +// if let cancelHash { +// self?.sync { +// guard let self else { return } +// if let cancel = self.cancellableSets.first(where: { $0.hashValue == cancelHash }) { +// self.cancellableSets.remove(cancel) +// } +// self.cancellableHashTable[download]?.removeAll(where: { $0 == cancelHash }) +// } +// } +// } receiveValue: { output in +// continuation.yield(.success(output)) +// } +// cancellableSets.insert(cancellable) +// cancelHash = cancellable.hashValue +// var values = cancellableHashTable[download] ?? [] +// values.append(cancelHash!) +// cancellableHashTable[download] = values +// } +// } +// } + + public func download(for download: Download) -> AsyncStream> { + let coordinator = self.coordinator + let publishers = self.publishers + return AsyncStream { continuation in + Task { + let _ = await publishers.store(download, coordinator: coordinator, action: { result in + switch result { + case .success(let info): + print("yield \(info)") + continuation.yield(.success(info)) + case .failure(let error): + continuation.yield(with: .success(.failure(error))) + } + }) } - - self.publishers[download] = nil } } - private var publishers: [Download: DownloadTaskPublisher] = [:] +// public func reset(download: Download) { +// async { [weak self] in +// guard let self = self else { +// return +// } +// +// self.publishers[download] = nil +// if let hashValues = self.cancellableHashTable[download] { +// let cancellables = self.cancellableSets.filter({ hashValues.contains($0.hashValue) }) +// for cancellable in cancellables { +// self.cancellableSets.remove(cancellable) +// cancellable.cancel() +// } +// } +// } +// Task { +// await publishers.remove(download) +// } +// } + +// private var publishers: [Download: DownloadTaskPublisher] = [:] + private let publishers = PublishersHolder() private let serialQueue = DispatchQueue(label: "DownloadManager.serialQueue") - private func async(_ closure: @escaping () -> Void) { - serialQueue.async(execute: closure) - } +// private func async(_ closure: @escaping () -> Void) { +// serialQueue.async(execute: closure) +// } +// +// private func sync(_ closure: () -> T) -> T { +// serialQueue.sync(execute: closure) +// } +} - private func sync(_ closure: () -> T) -> T { - serialQueue.sync(execute: closure) +//@available(macOS 11.0, *) +//struct Downloader: AsyncSequence { +// typealias AsyncIterator = Iterator +// typealias Element = DownloadInfo +// let download: Download +// let manager: DownloadManager +// +// struct Iterator: AsyncIteratorProtocol { +// let download: Download +// let manager: DownloadManager +// +// func next() async throws -> Element? { +// +// } +// } +// +// func makeAsyncIterator() -> Iterator { +// Iterator(download: download, manager: manager) +// +// manager.coordinator.startDownload(download, +// receiveResponse: { _ in +// }, +// receiveData: { _, _ in +// }, +// reportProgress: { _, progress in +// let _ = self.subscriber?.receive(.progress(progress)) +// }, +// completion: { [weak self] _, result in +// guard let self = self else { +// return +// } +// +// switch result { +// case .success(let downloadResult): +// switch downloadResult { +// case .data(let data): +// break +// case .file(let path): +// break +// } +// case .failure(let error): +// break +// } +// +// self.manager.reset(download: self.download) +// }) +// } +//} + +enum DownloadEventError: Error { + case cancelled +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +actor PublishersHolder: Sendable { + private var publishers: [URL: DownloadManager.DownloadTaskPublisher] = [:] + private var cancellableHashTable = [UUID: AnyCancellable]() + private var cancellables = [URL: Set]() + private let subject = AsyncBufferedChannel() + private var loaded = false + + deinit { + subject.finish() + } + + func store(_ download: Download, coordinator: URLSessionCoordinator, action: @escaping (Result) -> Void) { + if !loaded { + loaded = true + let subject = self.subject + Task { + for await uuid in subject { + pop(uuid) + } + print("publisher obseration finished.") + } + } + + let publisher: Publishers.Share + if let current = publishers[download.url] { + publisher = current + print("use exists task for \(download.url)") + } else { + publisher = DownloadPublisher(download: download, coordinator: coordinator).share() + publishers[download.url] = publisher + print("create new publisher for \(download.url)") + } + let uuid = download.id + let subject = self.subject + cancellableHashTable[uuid] = publisher + .handleEvents(receiveCancel: { + action(.failure(DownloadEventError.cancelled)) + }).sink { completion in + switch completion { + case .finished: + break + case .failure(let error): + action(.failure(error)) + } + + subject.send(uuid) + } receiveValue: { output in + action(.success(output)) + } + var uuids = cancellables[download.url] ?? [] + uuids.insert(uuid) + cancellables[download.url] = uuids + } + +// func remove(_ download: Download) { +// publishers.removeValue(forKey: download) +// for uuid in cancellables[download] ?? [] { +// cancellableHashTable[uuid]?.cancel() +// cancellableHashTable.removeValue(forKey: uuid) +// } +// cancellables.removeValue(forKey: download) +// } + + func pop(_ uuid: UUID) { + if let task = cancellableHashTable[uuid] { + cancellableHashTable.removeValue(forKey: uuid) + task.cancel() + } + + for (key, value) in cancellables.filter({ $0.value.contains(uuid) }) { + var next = value + next.remove(uuid) + cancellables[key] = next + } } } diff --git a/Dependencies/Sources/DownloadManager/URLSession/URLSessionCoordinator.swift b/Dependencies/Sources/DownloadManager/URLSession/URLSessionCoordinator.swift index be5ac94..64a3b59 100644 --- a/Dependencies/Sources/DownloadManager/URLSession/URLSessionCoordinator.swift +++ b/Dependencies/Sources/DownloadManager/URLSession/URLSessionCoordinator.swift @@ -12,7 +12,7 @@ import Log /// `URLSessionCoordinator` manages `URLSession` instance and forwards callbacks to responding `DownloadController` instances. @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -final class URLSessionCoordinator { +final class URLSessionCoordinator: @unchecked Sendable { init(urlSessionConfiguration: URLSessionConfiguration) { let delegate = URLSessionDelegate() @@ -184,7 +184,7 @@ final class URLSessionCoordinator { private let serialQueue = DispatchQueue(label: "URLSessionCoordinator.serialQueue") - private func async(_ closure: @escaping () -> Void) { + private func async(_ closure: @Sendable @escaping () -> Void) { serialQueue.async(execute: closure) } diff --git a/Dependencies/Sources/DownloadManager/URLSession/URLSessionDelegate.swift b/Dependencies/Sources/DownloadManager/URLSession/URLSessionDelegate.swift index d400f56..da69252 100644 --- a/Dependencies/Sources/DownloadManager/URLSession/URLSessionDelegate.swift +++ b/Dependencies/Sources/DownloadManager/URLSession/URLSessionDelegate.swift @@ -9,12 +9,12 @@ import Foundation import Log -final class URLSessionDelegate : NSObject { +final class URLSessionDelegate : NSObject, @unchecked Sendable { // URLSessionTaskDelegate /// func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) - typealias TaskDidCompleteWithError = (_ task: URLSessionTask, _ error: Error?) -> Void + typealias TaskDidCompleteWithError = @Sendable (_ task: URLSessionTask, _ error: Error?) -> Void private var taskDidCompleteWithError: TaskDidCompleteWithError? @@ -27,7 +27,7 @@ final class URLSessionDelegate : NSObject { // URLSessionDataDelegate /// func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) - typealias DataTaskDidReceiveResponse = (_ task: URLSessionDataTask, _ response: URLResponse, _ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) -> Void + typealias DataTaskDidReceiveResponse = (_ task: URLSessionDataTask, _ response: URLResponse, _ completionHandler: @Sendable @escaping (URLSession.ResponseDisposition) -> Void) -> Void private var dataTaskDidReceiveResponse: DataTaskDidReceiveResponse? @@ -117,7 +117,7 @@ extension URLSessionDelegate : URLSessionTaskDelegate { extension URLSessionDelegate : URLSessionDataDelegate { - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @Sendable @escaping (URLSession.ResponseDisposition) -> Void) { log_debug(self, #function, "\(String(describing: dataTask.originalRequest))", detail: log_detailed) dataTaskDidReceiveResponse?(dataTask, response, completionHandler) } diff --git a/Dependencies/Sources/FileIndex/File.swift b/Dependencies/Sources/FileIndex/File.swift index 9aeec72..42e90bf 100644 --- a/Dependencies/Sources/FileIndex/File.swift +++ b/Dependencies/Sources/FileIndex/File.swift @@ -11,7 +11,7 @@ import PlainDatabase @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct File : Identifiable { +public struct File : Identifiable, Sendable { public var id: String diff --git a/Dependencies/Sources/FileIndex/FileIndex.swift b/Dependencies/Sources/FileIndex/FileIndex.swift index a212c0e..8bc3fd1 100644 --- a/Dependencies/Sources/FileIndex/FileIndex.swift +++ b/Dependencies/Sources/FileIndex/FileIndex.swift @@ -13,9 +13,9 @@ import Model @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public class FileIndex { +public final class FileIndex: Sendable { - public struct Configuration { + public struct Configuration: Sendable { /// URL of the directory to keep file index, typically `~/Library/Caches/
` public var directoryURL: URL diff --git a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift index 9d7037e..33d44cf 100644 --- a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift +++ b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift @@ -6,7 +6,7 @@ // // ImageDecoder is based on ImageDecoderCG from WebCore https://trac.webkit.org/browser/webkit/trunk/Source/WebCore/platform/graphics/cg/ImageDecoderCG.cpp -import ImageIO +@preconcurrency import ImageIO import Foundation #if canImport(MobileCoreServices) @@ -17,9 +17,8 @@ import MobileCoreServices import Cocoa #endif - @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public final class ImageDecoder { +public final class ImageDecoder: Sendable { public struct DecodingOptions { @@ -86,23 +85,23 @@ public final class ImageDecoder { guard let dataProvider = CGDataProvider(url: url as CFURL) else { return nil } - + self.init() setDataProvider(dataProvider, allDataReceived: true) } - public private(set) var isAllDataReceived: Bool = false - +// public var isAllDataReceived: Bool = false + public func setData(_ data: Data, allDataReceived: Bool) { - assert(!isAllDataReceived) - - isAllDataReceived = allDataReceived +// assert(!isAllDataReceived) +// +// isAllDataReceived = allDataReceived CGImageSourceUpdateData(imageSource, data as CFData, allDataReceived) } public func setDataProvider(_ dataProvider: CGDataProvider, allDataReceived: Bool) { - assert(!isAllDataReceived) - isAllDataReceived = allDataReceived +// assert(!isAllDataReceived) +// isAllDataReceived = allDataReceived CGImageSourceUpdateDataProvider(imageSource, dataProvider, allDataReceived) } @@ -125,8 +124,8 @@ public final class ImageDecoder { fallthrough case .statusReadingHeader: // Ragnaros yells: TOO SOON! You have awakened me TOO SOON, Executus! - return isAllDataReceived ? .error : .unknown - +// return isAllDataReceived ? .error : .unknown + return .error case .statusIncomplete: // WebCore checks isSupportedImageType here and returns error if not: // if (!isSupportedImageType(uti)) @@ -233,7 +232,7 @@ public final class ImageDecoder { case .synchronous: options = imageSourceOptions(with: subsamplingLevel) - image = CGImageSourceCreateImageAtIndex(imageSource, index, options) + image = CGImageSourceCreateThumbnailAtIndex(imageSource, index, options) } // WebKit has support for xbm images but we don't @@ -273,16 +272,16 @@ public final class ImageDecoder { // MARK: - Private - private static let imageSourceOptions: [CFString: Any] = [ + nonisolated(unsafe) private static let imageSourceOptions: [CFString: Any] = [ kCGImageSourceShouldCache: true ] - private static let imageSourceAsyncOptions: [CFString: Any] = [ + nonisolated(unsafe) private static let imageSourceAsyncOptions: [CFString: Any] = [ kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailFromImageAlways: true ] - private let imageSource: CGImageSource + public let imageSource: CGImageSource private func imageSourceOptions(with subsamplingLevel: SubsamplingLevel = .default) -> CFDictionary { var options = ImageDecoder.imageSourceOptions diff --git a/Dependencies/Sources/Model/ImageInfo.swift b/Dependencies/Sources/Model/ImageInfo.swift index 5852572..af07e15 100644 --- a/Dependencies/Sources/Model/ImageInfo.swift +++ b/Dependencies/Sources/Model/ImageInfo.swift @@ -9,9 +9,10 @@ import CoreGraphics @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct ImageInfo { +public struct ImageInfo: Sendable { /// Decoded image + @MainActor public var cgImage: CGImage { proxy.cgImage } diff --git a/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift b/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift index 1c5621c..9ebe35e 100644 --- a/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift +++ b/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift @@ -9,16 +9,21 @@ import Foundation import CoreGraphics import ImageIO import ImageDecoder +import DownloadManager @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension TransientImage { + + static func create(data: Data, maxPixelSize: CGSize?) async -> Self? { + Self.init(data: data, maxPixelSize: maxPixelSize) + } init?(data: Data, maxPixelSize: CGSize?) { let decoder = ImageDecoder() decoder.setData(data, allDataReceived: true) - self.init(decoder: decoder, maxPixelSize: maxPixelSize) + self.init(decoder: decoder, presentation: .data(data), maxPixelSize: maxPixelSize) } init?(location: URL, maxPixelSize: CGSize?) { @@ -26,10 +31,10 @@ public extension TransientImage { return nil } - self.init(decoder: decoder, maxPixelSize: maxPixelSize) + self.init(decoder: decoder, presentation: .file(location.path), maxPixelSize: maxPixelSize) } - init?(decoder: ImageDecoder, maxPixelSize: CGSize?) { + init?(decoder: ImageDecoder, presentation: DownloadResult, maxPixelSize: CGSize?) { guard let uti = decoder.uti else { // Not an image return nil @@ -45,6 +50,6 @@ public extension TransientImage { let info = ImageInfo(proxy: proxy, size: size) let cgOrientation: CGImagePropertyOrientation = decoder.frameOrientation(at: 0) ?? .up - self.init(proxy: proxy, info: info, uti: uti, cgOrientation: cgOrientation) + self.init(proxy: proxy, info: info, uti: uti, presentation: presentation, cgOrientation: cgOrientation) } } diff --git a/Dependencies/Sources/Model/TransientImage.swift b/Dependencies/Sources/Model/TransientImage.swift index 079983f..1e74d5a 100644 --- a/Dependencies/Sources/Model/TransientImage.swift +++ b/Dependencies/Sources/Model/TransientImage.swift @@ -8,13 +8,14 @@ import Foundation import ImageIO import ImageDecoder +import DownloadManager /// Temporary representation used after decoding an image from data or file on disk and before creating an image object for display. @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct TransientImage { +public struct TransientImage: Sendable { - public var cgImage: CGImage { + @MainActor public var cgImage: CGImage { proxy.cgImage } @@ -27,24 +28,25 @@ public struct TransientImage { public let cgOrientation: CGImagePropertyOrientation - init(proxy: CGImageProxy, info: ImageInfo, uti: String, cgOrientation: CGImagePropertyOrientation) { + init(proxy: CGImageProxy, info: ImageInfo, uti: String, presentation: DownloadResult, cgOrientation: CGImagePropertyOrientation) { self.proxy = proxy self.info = info self.uti = uti self.cgOrientation = cgOrientation + self.presentation = presentation } - - private let proxy: CGImageProxy + + public let presentation: DownloadResult + public let proxy: CGImageProxy } - /// Proxy used to decode image lazily @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -final class CGImageProxy { +public final class CGImageProxy: Sendable { - let decoder: ImageDecoder + public let decoder: ImageDecoder - let maxPixelSize: CGSize? + public let maxPixelSize: CGSize? init(decoder: ImageDecoder, maxPixelSize: CGSize?) { self.decoder = decoder @@ -58,8 +60,8 @@ final class CGImageProxy { return decodedCGImage! } - - private var decodedCGImage: CGImage? + + nonisolated(unsafe) private var decodedCGImage: CGImage? private func decodeImage() { if let sizeForDrawing = maxPixelSize { diff --git a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataAttributeDescription.swift b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataAttributeDescription.swift index 322e9a5..c76d5c6 100644 --- a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataAttributeDescription.swift +++ b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataAttributeDescription.swift @@ -10,7 +10,7 @@ import CoreData /// Used to create `NSAttributeDescription` @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public struct CoreDataAttributeDescription { +public struct CoreDataAttributeDescription: Sendable { public static func attribute(name: String, type: NSAttributeType, isOptional: Bool = false) -> CoreDataAttributeDescription { CoreDataAttributeDescription(name: name, attributeType: type, isOptional: isOptional) diff --git a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataEntityDescription.swift b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataEntityDescription.swift index 095cecb..32f3c96 100644 --- a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataEntityDescription.swift +++ b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataEntityDescription.swift @@ -10,7 +10,7 @@ import CoreData /// Used to create `NSEntityDescription` @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public struct CoreDataEntityDescription { +public struct CoreDataEntityDescription: Sendable { public init(name: String, managedObjectClassName: String, attributes: [CoreDataAttributeDescription], indexes: [CoreDataFetchIndexDescription]) { self.name = name diff --git a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataFetchIndexDescription.swift b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataFetchIndexDescription.swift index b2b69a8..e680ebd 100644 --- a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataFetchIndexDescription.swift +++ b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataFetchIndexDescription.swift @@ -10,12 +10,12 @@ import CoreData /// Describes `NSFetchIndexDescription` @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public struct CoreDataFetchIndexDescription { +public struct CoreDataFetchIndexDescription: Sendable { /// Describes `NSFetchIndexElementDescription` - public struct Element { + public struct Element: Sendable { - public enum Property { + public enum Property: Sendable { case property(name: String) } diff --git a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataModelDescription.swift b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataModelDescription.swift index 8318841..257f213 100644 --- a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataModelDescription.swift +++ b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataModelDescription.swift @@ -10,7 +10,7 @@ import CoreData /// Used to create `NSManagedObjectModel`. @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public struct CoreDataModelDescription { +public struct CoreDataModelDescription: Sendable { public var entity: CoreDataEntityDescription diff --git a/Dependencies/Sources/PlainDatabase/Database.swift b/Dependencies/Sources/PlainDatabase/Database.swift index 8d884d8..e09d953 100644 --- a/Dependencies/Sources/PlainDatabase/Database.swift +++ b/Dependencies/Sources/PlainDatabase/Database.swift @@ -6,11 +6,11 @@ // import Foundation -import CoreData +@preconcurrency import CoreData @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public final class Database { +public final class Database: Sendable { public struct Configuration { @@ -45,10 +45,41 @@ public final class Database { context.undoManager = nil } + public func async(_ closure: @escaping (_ context: NSManagedObjectContext) throws -> Void) async { + if #available(macOS 12.0, iOS 15.0, *) { + do { + try await context.perform(schedule: .immediate) { [weak context] in + guard let context = context else { + return + } + try closure(context) + } + } catch { + print(error) + } + } else { + // Fallback on earlier versions + context.perform { [weak context] in + guard let context = context else { + return + } + do { + try closure(context) + } + catch { + print(error) + } + } + } + } + public func async(_ closure: @escaping (_ context: NSManagedObjectContext) throws -> Void) { - context.perform { [unowned self] in + context.perform { [weak context] in + guard let context = context else { + return + } do { - try closure(self.context) + try closure(context) } catch { print(error) @@ -57,9 +88,12 @@ public final class Database { } public func sync(_ closure: (_ context: NSManagedObjectContext) throws -> Void) { - context.performAndWait { [unowned self] in + context.performAndWait { [weak context] in + guard let context = context else { + return + } do { - try closure(self.context) + try closure(context) } catch { print(error) @@ -71,9 +105,12 @@ public final class Database { public func sync(_ closure: (_ context: NSManagedObjectContext) throws -> T) throws -> T { var result: Result? = nil - context.performAndWait { [unowned self] in + context.performAndWait { [weak context] in + guard let context = context else { + return + } do { - result = .success(try closure(self.context)) + result = .success(try closure(context)) } catch { result = .failure(error) diff --git a/Dependencies/Sources/PlainDatabase/PlainDatabase.swift b/Dependencies/Sources/PlainDatabase/PlainDatabase.swift index 88fcc2c..0fff9f7 100644 --- a/Dependencies/Sources/PlainDatabase/PlainDatabase.swift +++ b/Dependencies/Sources/PlainDatabase/PlainDatabase.swift @@ -13,7 +13,7 @@ import CoreData public typealias PlainDatabaseObject = ManagedObjectCodable -public enum PredicateOperator { +public enum PredicateOperator: Sendable { case lessThan @@ -70,7 +70,7 @@ public enum PredicateOperator { A database that stores a plain list of same type objects. */ @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public final class PlainDatabase { +public final class PlainDatabase: Sendable { public init(configuration: Database.Configuration, modelDescription: CoreDataModelDescription) { database = Database(configuration: configuration, model: modelDescription.model) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..7a613de --- /dev/null +++ b/Package.resolved @@ -0,0 +1,25 @@ +{ + "object": { + "pins": [ + { + "package": "AsyncExtensions", + "repositoryURL": "https://github.com/0xfeedface1993/AsyncExtensions.git", + "state": { + "branch": null, + "revision": "e053d0fff37f352525554830aaa6573babdb4184", + "version": "1.0.3" + } + }, + { + "package": "swift-collections", + "repositoryURL": "https://github.com/apple/swift-collections.git", + "state": { + "branch": null, + "revision": "9bf03ff58ce34478e66aaee630e491823326fd06", + "version": "1.1.3" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index f3f026e..c3fec61 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,9 +6,9 @@ import PackageDescription let package = Package( name: "URLImage", platforms: [ - .iOS(.v12), + .iOS(.v14), .tvOS(.v12), - .macOS(.v10_13), + .macOS(.v10_15), .watchOS(.v4) ], products: [ @@ -23,6 +23,7 @@ let package = Package( dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/0xfeedface1993/AsyncExtensions.git", from: "1.0.3") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -45,14 +46,14 @@ let package = Package( path: "Dependencies/Sources/PlainDatabase"), .target( name: "DownloadManager", - dependencies: [ "Log" ], + dependencies: [ "Log", "AsyncExtensions" ], path: "Dependencies/Sources/DownloadManager"), .target( name: "Log", path: "Dependencies/Sources/Log"), .target( name: "Model", - dependencies: [ "ImageDecoder" ], + dependencies: [ "ImageDecoder", "DownloadManager" ], path: "Dependencies/Sources/Model"), .testTarget( name: "URLImageTests", diff --git a/README.md b/README.md index 9b9d990..df94c3e 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,30 @@ struct MyApp: App { } ``` +### GIF + +By default, `URLImage` can display GIF images, but it only displays the first frame of the GIF as a static image. To display the ***animated GIF***, you should use `GIFImage`. The default display mode for image is similar to SwiftUI's default `Image`, but to achieve scaling effects, you need to call the `.aspectResizeble(ratio:,contentMode:)` method. Additionally, ***Zoom In*** operation is supported. + +```swift +GIFImage(item.imageURL) { + // This view is displayed before download starts + EmptyView() +} inProgress: { progress in + // Display progress + Text("Loading...") +} failure: { error, retry in + // Display error and retry button + VStack { + Text(error.localizedDescription) + Button("Retry", action: retry) + } +} content: { image in + // Downloaded GIF image + image + .aspectResizeble(ratio: ratio, contentMode: .fit) +} +``` + ### Image Information You can use `ImageInfo` structure if you need information about an image, like actual size, or access the underlying `CGImage` object. `ImageInfo` is an argument of `content` view builder closure. diff --git a/Sources/URLImage/Common/Backport.swift b/Sources/URLImage/Common/Backport.swift new file mode 100644 index 0000000..0a1862f --- /dev/null +++ b/Sources/URLImage/Common/Backport.swift @@ -0,0 +1,54 @@ +// +// File.swift +// URLImage +// +// Created by sonoma on 8/1/24. +// + +import Foundation +import SwiftUI + +struct Backport { + let content: Content +} + +extension View { + nonisolated var backport: Backport { + Backport(content: self) + } +} + +extension Backport { + @inlinable + @MainActor + @ViewBuilder + func task(priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + content.task(priority: priority, action) + } else { + content.modifier(TaskModifier(priority: priority, action: action)) + } + } +} + +struct TaskModifier: ViewModifier { + @State private var loaded = false + var priority: TaskPriority + var action: () async -> Void + + init(priority: TaskPriority, action: @escaping @Sendable () async -> Void) { + self.priority = priority + self.action = action + } + + func body(content: Content) -> some View { + content.onAppear { + if !loaded { + loaded = true + Task(priority: priority, operation: { + await action() + }) + } + } + } +} diff --git a/Sources/URLImage/Common/Synchronized.swift b/Sources/URLImage/Common/Synchronized.swift index 3b6bb36..6a0ad5e 100644 --- a/Sources/URLImage/Common/Synchronized.swift +++ b/Sources/URLImage/Common/Synchronized.swift @@ -12,7 +12,7 @@ import Foundation /// /// `Synchronized` provides getter/setter synchronization, thread safety of the wrapped value is up to its implementation. @propertyWrapper -public final class Synchronized { +public final class Synchronized: @unchecked Sendable { public var wrappedValue: Value { get { diff --git a/Sources/URLImage/Common/TransientImage+SwiftUI.swift b/Sources/URLImage/Common/TransientImage+SwiftUI.swift index a4dbb94..eba52dd 100644 --- a/Sources/URLImage/Common/TransientImage+SwiftUI.swift +++ b/Sources/URLImage/Common/TransientImage+SwiftUI.swift @@ -12,6 +12,7 @@ import Model @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension TransientImage { + @MainActor var image: Image { let orientation = Image.Orientation(cgOrientation) return Image(decorative: self.cgImage, scale: 1.0, orientation: orientation) diff --git a/Sources/URLImage/Common/URLImageKey.swift b/Sources/URLImage/Common/URLImageKey.swift index eaf8134..4cc7f5a 100644 --- a/Sources/URLImage/Common/URLImageKey.swift +++ b/Sources/URLImage/Common/URLImageKey.swift @@ -9,7 +9,7 @@ import Foundation /// Various key types used to access images. -public enum URLImageKey { +public enum URLImageKey: Sendable { /// Unique identifier as a string case identifier(_ identifier: String) diff --git a/Sources/URLImage/EnvironmentValues+URLImage.swift b/Sources/URLImage/EnvironmentValues+URLImage.swift index 1486577..28b2ccf 100644 --- a/Sources/URLImage/EnvironmentValues+URLImage.swift +++ b/Sources/URLImage/EnvironmentValues+URLImage.swift @@ -7,43 +7,12 @@ import SwiftUI - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -private struct URLImageServiceEnvironmentKey: EnvironmentKey { - - static let defaultValue: URLImageService = URLImageService() -} - - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -private struct URLImageOptionsEnvironmentKey: EnvironmentKey { - - static let defaultValue: URLImageOptions = URLImageOptions() -} - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension EnvironmentValues { /// Service used by instances of the `URLImage` view - var urlImageService: URLImageService { - get { - self[URLImageServiceEnvironmentKey.self] - } - - set { - self[URLImageServiceEnvironmentKey.self] = newValue - } - } + @Entry var urlImageService: URLImageService = URLImageService() /// Options object used by instances of the `URLImage` view - var urlImageOptions: URLImageOptions { - get { - self[URLImageOptionsEnvironmentKey.self] - } - - set { - self[URLImageOptionsEnvironmentKey.self] = newValue - } - } + @Entry var urlImageOptions: URLImageOptions = URLImageOptions() } diff --git a/Sources/URLImage/RemoteImage/RemoteImage.swift b/Sources/URLImage/RemoteImage/RemoteImage.swift index 7e9d166..500bb78 100644 --- a/Sources/URLImage/RemoteImage/RemoteImage.swift +++ b/Sources/URLImage/RemoteImage/RemoteImage.swift @@ -5,15 +5,22 @@ // Created by Dmytro Anokhin on 25/08/2020. // -import SwiftUI -import Combine +@preconcurrency import SwiftUI +@preconcurrency import Combine import Model import DownloadManager import ImageDecoder import Log +fileprivate let queue = DispatchQueue(label: "com.url.image.workitem") + +@available(macOS 11.0, *) +final class RemoteImageWrapper: ObservableObject { + @Published var remote: RemoteImage? +} @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +@MainActor public final class RemoteImage : ObservableObject { /// Reference to URLImageService used to download and store the image. @@ -25,84 +32,192 @@ public final class RemoteImage : ObservableObject { let identifier: String? let options: URLImageOptions + + nonisolated(unsafe) var stateCancellable: AnyCancellable? + nonisolated(unsafe) var downloadTask: Task? init(service: URLImageService, download: Download, identifier: String?, options: URLImageOptions) { self.service = service self.download = download self.identifier = identifier self.options = options + stateBind() log_debug(nil, #function, download.url.absoluteString) + + } + + private func stateBind() { + self.stateCancellable = loadingStatePublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] state in + self?.slowLoadingState.send(state) + }) } deinit { +// stateCancellable?.cancel() +// downloadTask?.cancel() log_debug(nil, #function, download.url.absoluteString, detail: log_detailed) } public typealias LoadingState = RemoteImageLoadingState /// External loading state used to update the view - @Published public private(set) var loadingState: LoadingState = .initial { - willSet { - log_debug(self, #function, "\(download.url) will transition from \(loadingState) to \(newValue)", detail: log_detailed) - } +// @Published public private(set) var loadingState: LoadingState = .initial { +// willSet { +// log_debug(self, #function, "\(download.url) will transition from \(loadingState) to \(newValue)", detail: log_detailed) +// } +// } + + let loadingState = CurrentValueSubject(.initial) + let slowLoadingState = CurrentValueSubject(.initial) +// @Published public private(set) var slowLoadingState: LoadingState = .initial +// nonisolated(unsafe) public var slowLoadingState: AnyPublisher { +// loadingState +// .prepend(.initial) +// .eraseToAnyPublisher() +// } + + private var progressStatePublisher: AnyPublisher { + loadingState + .filter({ $0.isInProgress }) + .collect(.byTime(DispatchQueue.main, .milliseconds(100))) + .compactMap(\.last) + .eraseToAnyPublisher() + } + + private var nonProgressStatePublisher: AnyPublisher { + loadingState + .filter({ !$0.isInProgress }) + .eraseToAnyPublisher() + } + + private var loadingStatePublisher: AnyPublisher { + nonProgressStatePublisher + .merge(with: progressStatePublisher) + .scan(LoadingState.initial, { current, next in + next.isInProgress && current.isComplete ? current:next + }) + .removeDuplicates() + .eraseToAnyPublisher() } - public func load() { +// public func load() { +// guard !isLoading else { +// return +// } +// +// log_debug(self, #function, "Start load for: \(download.url)", detail: log_normal) +// +// isLoading = true +// +// switch options.fetchPolicy { +// case .returnStoreElseLoad(let downloadDelay): +// guard !isLoadedSuccessfully else { +// // Already loaded +// isLoading = false +// return +// } +// +// guard !loadFromInMemoryStore() else { +// // Loaded from the in-memory store +// isLoading = false +// return +// } +// +// // Disk lookup +// // scheduleReturnStored(afterDelay: nil) { [weak self] success in +// // guard let self = self else { return } +// // +// // if !success { +// // self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) +// // } +// // } +// Task { +// let success = await scheduleReturnStored(afterDelay: nil) +// if !success { +// self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) +// } +// } +// +// case .returnStoreDontLoad: +// guard !isLoadedSuccessfully else { +// // Already loaded +// isLoading = false +// return +// } +// +// guard !loadFromInMemoryStore() else { +// // Loaded from the in-memory store +// isLoading = false +// return +// } +// +// // Disk lookup +// // scheduleReturnStored(afterDelay: nil) { [weak self] success in +// // guard let self = self else { return } +// // +// // if !success { +// // // Complete +// // self.loadingState = .initial +// // self.isLoading = false +// // } +// // } +// Task { +// let success = await scheduleReturnStored(afterDelay: nil) +// if !success { +// await updateLoadingState(.initial) +// await updateIsLoading(false) +// } +// } +// } +// } + public func load() async { guard !isLoading else { return } - + log_debug(self, #function, "Start load for: \(download.url)", detail: log_normal) - - isLoading = true - + + updateIsLoading(true) + switch options.fetchPolicy { - case .returnStoreElseLoad(let downloadDelay): - guard !isLoadedSuccessfully else { - // Already loaded - isLoading = false - return - } - - guard !loadFromInMemoryStore() else { - // Loaded from the in-memory store - isLoading = false - return - } - - // Disk lookup - scheduleReturnStored(afterDelay: nil) { [weak self] success in - guard let self = self else { return } - - if !success { - self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) - } - } - - case .returnStoreDontLoad: - guard !isLoadedSuccessfully else { - // Already loaded - isLoading = false - return - } - - guard !loadFromInMemoryStore() else { - // Loaded from the in-memory store - isLoading = false - return - } - - // Disk lookup - scheduleReturnStored(afterDelay: nil) { [weak self] success in - guard let self = self else { return } - - if !success { - // Complete - self.loadingState = .initial - self.isLoading = false - } - } + case .returnStoreElseLoad(let downloadDelay): + guard !isLoadedSuccessfully else { + // Already loaded + updateIsLoading(false) + return + } + + guard !loadFromInMemoryStore() else { + // Loaded from the in-memory store + updateIsLoading(false) + return + } + + let success = await scheduleReturnStored(afterDelay: nil) + if !success { + await self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) + } + case .returnStoreDontLoad: + guard !isLoadedSuccessfully else { + // Already loaded + updateIsLoading(false) + return + } + + guard !loadFromInMemoryStore() else { + // Loaded from the in-memory store + updateIsLoading(false) + return + } + + let success = await scheduleReturnStored(afterDelay: nil) + if !success { + updateLoadingState(.initial) + updateIsLoading(false) + } } } @@ -127,11 +242,14 @@ public final class RemoteImage : ObservableObject { delayedDownload?.cancel() delayedDownload = nil + +// downloadTask?.cancel() + downloadTask?.cancel() + downloadTask = nil } /// Internal loading state private var isLoading: Bool = false - private var cancellables = Set() private var delayedReturnStored: DispatchWorkItem? private var delayedDownload: DispatchWorkItem? @@ -142,7 +260,7 @@ public final class RemoteImage : ObservableObject { extension RemoteImage { private var isLoadedSuccessfully: Bool { - switch loadingState { + switch loadingState.value { case .success: return true default: @@ -153,6 +271,24 @@ extension RemoteImage { /// Rerturn an image from the in memory store. /// /// Sets `loadingState` to `.success` if an image is in the in-memory store and returns `true`. Otherwise returns `false` without changing the state. +// private func loadFromInMemoryStore() -> Bool { +// guard let store = service.inMemoryStore else { +// log_debug(self, #function, "Not using in memory store for \(download.url)", detail: log_normal) +// return false +// } +// +// guard let transientImage: TransientImage = store.getImage(keys) else { +// log_debug(self, #function, "Image for \(download.url) not in the in memory store", detail: log_normal) +// return false +// } +// +// // Complete +// self.loadingState = .success(transientImage) +// log_debug(self, #function, "Image for \(download.url) is in the in memory store", detail: log_normal) +// +// return true +// } + private func loadFromInMemoryStore() -> Bool { guard let store = service.inMemoryStore else { log_debug(self, #function, "Not using in memory store for \(download.url)", detail: log_normal) @@ -165,147 +301,241 @@ extension RemoteImage { } // Complete - self.loadingState = .success(transientImage) + self.updateLoadingState(.success(transientImage)) log_debug(self, #function, "Image for \(download.url) is in the in memory store", detail: log_normal) return true } - private func scheduleReturnStored(afterDelay delay: TimeInterval?, completion: @escaping (_ success: Bool) -> Void) { +// private func scheduleReturnStored(afterDelay delay: TimeInterval?, completion: @escaping (_ success: Bool) -> Void) { +// guard let delay = delay else { +// // Read from store immediately if no delay needed +// returnStored(completion) +// return +// } +// +// delayedReturnStored?.cancel() +// delayedReturnStored = DispatchWorkItem { [weak self] in +// guard let self = self else { return } +// self.returnStored(completion) +// } +// +// queue.asyncAfter(deadline: .now() + delay, execute: delayedReturnStored!) +// } + + private func scheduleReturnStored(afterDelay delay: TimeInterval?) async -> Bool { guard let delay = delay else { // Read from store immediately if no delay needed - returnStored(completion) - return + return await returnStored() } - - delayedReturnStored?.cancel() - delayedReturnStored = DispatchWorkItem { [weak self] in - guard let self = self else { return } - self.returnStored(completion) + + if #available(iOS 16.0, macOS 13, *) { + try? await Task.sleep(for: .seconds(delay)) + } else { + // Fallback on earlier versions + try? await Task.sleep(nanoseconds: UInt64(1_000_000_000 * delay)) } - - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: delayedReturnStored!) + + return await returnStored() } // Second store lookup is necessary for a case if the same image was downloaded by another instance of RemoteImage +// private func scheduleDownload(afterDelay delay: TimeInterval? = nil, secondStoreLookup: Bool = false) { +// guard let delay = delay else { +// // Start download immediately if no delay needed +// startDownload() +// return +// } +// +// delayedDownload?.cancel() +// delayedDownload = DispatchWorkItem { [weak self] in +// guard let self = self else { return } +// +// if secondStoreLookup { +// self.returnStored { [weak self] success in +// guard let self = self else { return } +// +// if !success { +// self.startDownload() +// } +// } +// } +// else { +// self.startDownload() +// } +// } +// +// queue.asyncAfter(deadline: .now() + delay, execute: delayedDownload!) +// } + private func scheduleDownload(afterDelay delay: TimeInterval? = nil, secondStoreLookup: Bool = false) { - guard let delay = delay else { + guard let _ = delay else { // Start download immediately if no delay needed - startDownload() + Task { + await startDownload() + } return } - - delayedDownload?.cancel() - delayedDownload = DispatchWorkItem { [weak self] in - guard let self = self else { return } - - if secondStoreLookup { - self.returnStored { [weak self] success in - guard let self = self else { return } - - if !success { - self.startDownload() - } + + if secondStoreLookup { + Task { + let success = await returnStored() + if !success { + await startDownload() } } - else { - self.startDownload() + } else { + Task { + await startDownload() } } - - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: delayedDownload!) } - private func startDownload() { - loadingState = .inProgress(nil) - - service.downloadManager.publisher(for: download) - .sink { [weak self] result in - guard let self = self else { - return - } - - switch result { - case .finished: - break - - case .failure(let error): - // This route happens when download fails - self.updateLoadingState(.failure(error)) - } - } - receiveValue: { [weak self] info in - guard let self = self else { - return - } - - switch info { - case .progress(let progress): - self.updateLoadingState(.inProgress(progress)) - case .completion(let result): - do { - let transientImage = try self.service.decode(result: result, - download: self.download, - identifier: self.identifier, - options: self.options) - self.updateLoadingState(.success(transientImage)) - } - catch { - // This route happens when download succeeds, but decoding fails - self.updateLoadingState(.failure(error)) - } +// private func startDownload() { +// loadingState = .inProgress(nil) +// +// service.downloadManager.publisher(for: download) +// .sink { [weak self] result in +// guard let self = self else { +// return +// } +// +// switch result { +// case .finished: +// break +// +// case .failure(let error): +// // This route happens when download fails +// self.updateLoadingState(.failure(error)) +// } +// } +// receiveValue: { [weak self] info in +// guard let self = self else { +// return +// } +// +// switch info { +// case .progress(let progress): +// self.updateLoadingState(.inProgress(progress)) +// case .completion(let result): +// do { +// let transientImage = try self.service.decode(result: result, +// download: self.download, +// identifier: self.identifier, +// options: self.options) +// self.updateLoadingState(.success(transientImage)) +// } +// catch { +// // This route happens when download succeeds, but decoding fails +// self.updateLoadingState(.failure(error)) +// } +// } +// } +// .store(in: &cancellables) +// } + private func startDownload() async { + updateLoadingState(.inProgress(nil)) + + let infos = service.downloadManager.download(for: download) + let download = download + let identifier = identifier + let options = options + let service = service + for await info in infos { + switch info { + case .success(let success): + switch success { + case .progress(let progress): + updateLoadingState(.inProgress(progress)) + case .completion(let result): + do { + let transientImage = try await service.decode(result: result, + download: download, + identifier: identifier, + options: options) + updateLoadingState(.success(transientImage)) + } catch { + // This route happens when download succeeds, but decoding fails + updateLoadingState(.failure(error)) + } } + case .failure(let error): + updateLoadingState(.failure(error)) } - .store(in: &cancellables) + } } - private func returnStored(_ completion: @escaping (_ success: Bool) -> Void) { - loadingState = .inProgress(nil) +// private func returnStored(_ completion: @escaping (_ success: Bool) -> Void) { +// Task { @MainActor in +// loadingState = .inProgress(nil) +// } +// +// guard let store = service.fileStore else { +// completion(false) +// return +// } +// +// store.getImagePublisher(keys, maxPixelSize: options.maxPixelSize) +// .receive(on: DispatchQueue.main) +// .catch { _ in +// Just(nil) +// } +// .sink { [weak self] in +// guard let self = self else { +// return +// } +// +// if let transientImage = $0 { +// log_debug(self, #function, "Image for \(self.download.url) is in the disk store", detail: log_normal) +// // Store in memory +// let info = URLImageStoreInfo(url: self.download.url, +// identifier: self.identifier, +// uti: transientImage.uti) +// +// self.service.inMemoryStore?.store(transientImage, info: info) +// +// // Complete +// self.loadingState = .success(transientImage) +// completion(true) +// } +// else { +// log_debug(self, #function, "Image for \(self.download.url) not in the disk store", detail: log_normal) +// completion(false) +// } +// } +// .store(in: &cancellables) +// } + + private func returnStored() async -> Bool { + loadingState.send(.inProgress(nil)) guard let store = service.fileStore else { - completion(false) - return + return false } - store.getImagePublisher(keys, maxPixelSize: options.maxPixelSize) - .receive(on: DispatchQueue.main) - .catch { _ in - Just(nil) - } - .sink { [weak self] in - guard let self = self else { - return - } - - if let transientImage = $0 { - log_debug(self, #function, "Image for \(self.download.url) is in the disk store", detail: log_normal) - // Store in memory - let info = URLImageStoreInfo(url: self.download.url, - identifier: self.identifier, - uti: transientImage.uti) + let transientImage = try? await store.getImage(keys, maxPixelSize: options.maxPixelSize) + guard let transientImage else { + log_debug(self, #function, "Image for \(download.url) not in the disk store", detail: log_normal) + return false + } + + log_debug(self, #function, "Image for \(self.download.url) is in the disk store", detail: log_normal) + // Store in memory + let info = URLImageStoreInfo(url: download.url, identifier: identifier, uti: transientImage.uti) - self.service.inMemoryStore?.store(transientImage, info: info) + service.inMemoryStore?.store(transientImage, info: info) - // Complete - self.loadingState = .success(transientImage) - completion(true) - } - else { - log_debug(self, #function, "Image for \(self.download.url) not in the disk store", detail: log_normal) - completion(false) - } - } - .store(in: &cancellables) + // Complete + loadingState.send(.success(transientImage)) + return true } - + private func updateLoadingState(_ loadingState: LoadingState) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - - self.loadingState = loadingState - } + self.loadingState.send(loadingState) + } + + private func updateIsLoading(_ loading: Bool) { + self.isLoading = loading } /// Helper to return `URLImageStoreKey` objects based on `URLImageOptions` and `Download` properties diff --git a/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift b/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift index c263977..64d9c1e 100644 --- a/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift +++ b/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift @@ -17,7 +17,7 @@ import Model /// This dual purpose allows the view to use switch statement in its `body` and return different view in each case. /// @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public enum RemoteImageLoadingState { +public enum RemoteImageLoadingState: Sendable { case initial @@ -28,6 +28,24 @@ public enum RemoteImageLoadingState { case failure(_ error: Error) } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension RemoteImageLoadingState: @preconcurrency Equatable { + @MainActor public static func == (lhs: RemoteImageLoadingState, rhs: RemoteImageLoadingState) -> Bool { + switch (lhs, rhs) { + case (.initial, .initial): + return true + case (.inProgress(let lp), .inProgress(let rp)): + return lp == rp + case (.success(let lv), .success(let rv)): + return lv.image == rv.image + case (.failure(_), .failure(_)): + return true + default: + return false + } + } +} + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension RemoteImageLoadingState { diff --git a/Sources/URLImage/Service/URLImageService+Decode.swift b/Sources/URLImage/Service/URLImageService+Decode.swift index 2905af7..2f2b6ea 100644 --- a/Sources/URLImage/Service/URLImageService+Decode.swift +++ b/Sources/URLImage/Service/URLImageService+Decode.swift @@ -13,7 +13,7 @@ import DownloadManager @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension URLImageService { - func decode(result: DownloadResult, download: Download, identifier: String?, options: URLImageOptions) throws -> TransientImage { + func decode(result: DownloadResult, download: Download, identifier: String?, options: URLImageOptions) async throws -> TransientImage { switch result { case .data(let data): diff --git a/Sources/URLImage/Service/URLImageService+RemoteImage.swift b/Sources/URLImage/Service/URLImageService+RemoteImage.swift index 5c36f81..0a2bb14 100644 --- a/Sources/URLImage/Service/URLImageService+RemoteImage.swift +++ b/Sources/URLImage/Service/URLImageService+RemoteImage.swift @@ -6,10 +6,11 @@ // import Foundation -import Combine +@preconcurrency import Combine import Model import DownloadManager +extension Published.Publisher: @unchecked Sendable where Output: Sendable { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension URLImageService { @@ -47,43 +48,103 @@ extension URLImageService { } private var cancellable: AnyCancellable? + private var task: Task? + private var task2: Task? func request(_ demand: Subscribers.Demand) { guard demand > 0 else { return } - - cancellable = remoteImage.$loadingState.sink(receiveValue: { [weak self] loadingState in - guard let self = self else { - return + + let remote = remoteImage + nonisolated(unsafe) let subscriber = subscriber + + let operation: @Sendable () async -> Void = { + if #available(macOS 12.0, iOS 15, *) { + let state = await remote.loadingState + for await loadingState in state.values { + switch loadingState { + case .initial: + break + + case .inProgress: + break + + case .success(let transientImage): + let _ = subscriber?.receive(transientImage.info) + subscriber?.receive(completion: .finished) + + case .failure(let error): + subscriber?.receive(completion: .failure(error)) + } + } + } else { + // Fallback on earlier versions } - - switch loadingState { - case .initial: - break - - case .inProgress: - break - - case .success(let transientImage): - let _ = self.subscriber?.receive(transientImage.info) - self.subscriber?.receive(completion: .finished) - - case .failure(let error): - self.subscriber?.receive(completion: .failure(error)) +// await remote.loadingState.sink(receiveValue: { loadingState in +// switch loadingState { +// case .initial: +// break +// +// case .inProgress: +// break +// +// case .success(let transientImage): +// let _ = subscriber?.receive(transientImage.info) +// subscriber?.receive(completion: .finished) +// +// case .failure(let error): +// subscriber?.receive(completion: .failure(error)) +// } +// }) + } + + task2 = Task(operation: operation) +// cancellable = remote.loadingState.sink(receiveValue: { [weak self] loadingState in +// guard let self = self else { +// return +// } +// +// switch loadingState { +// case .initial: +// break +// +// case .inProgress: +// break +// +// case .success(let transientImage): +// let _ = self.subscriber?.receive(transientImage.info) +// self.subscriber?.receive(completion: .finished) +// +// case .failure(let error): +// self.subscriber?.receive(completion: .failure(error)) +// } +// }) + + task = Task { + await withTaskCancellationHandler { + await remote.load() + } onCancel: { + Task { + await remote.cancel() + } } - }) - - remoteImage.load() + } } func cancel() { - remoteImage.cancel() + let remote = remoteImage + Task { + await remote.cancel() + } + task?.cancel() + task2?.cancel() cancellable = nil + task = nil } } - public func makeRemoteImage(url: URL, identifier: String?, options: URLImageOptions) -> RemoteImage { + @MainActor public func makeRemoteImage(url: URL, identifier: String?, options: URLImageOptions) -> RemoteImage { let inMemory = fileStore == nil let destination = makeDownloadDestination(inMemory: inMemory) @@ -94,7 +155,7 @@ extension URLImageService { return RemoteImage(service: self, download: download, identifier: identifier, options: options) } - public func remoteImagePublisher(_ url: URL, identifier: String?, options: URLImageOptions = URLImageOptions()) -> RemoteImagePublisher { + @MainActor public func remoteImagePublisher(_ url: URL, identifier: String?, options: URLImageOptions = URLImageOptions()) -> RemoteImagePublisher { let remoteImage = makeRemoteImage(url: url, identifier: identifier, options: options) return RemoteImagePublisher(remoteImage: remoteImage) } diff --git a/Sources/URLImage/Service/URLImageService.swift b/Sources/URLImage/Service/URLImageService.swift index 8d81bec..28c3cad 100644 --- a/Sources/URLImage/Service/URLImageService.swift +++ b/Sources/URLImage/Service/URLImageService.swift @@ -13,7 +13,7 @@ import DownloadManager @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public class URLImageService { +public class URLImageService: @unchecked Sendable { public init(fileStore: URLImageFileStoreType? = nil, inMemoryStore: URLImageInMemoryStoreType? = nil) { self.fileStore = fileStore diff --git a/Sources/URLImage/Store/Common/URLImageStoreInfo.swift b/Sources/URLImage/Store/Common/URLImageStoreInfo.swift index f159e44..b417f1f 100644 --- a/Sources/URLImage/Store/Common/URLImageStoreInfo.swift +++ b/Sources/URLImage/Store/Common/URLImageStoreInfo.swift @@ -9,7 +9,7 @@ import Foundation /// Information that describes an image in a store -public struct URLImageStoreInfo { +public struct URLImageStoreInfo: Sendable { /// Original URL of the image public var url: URL @@ -19,4 +19,10 @@ public struct URLImageStoreInfo { /// The uniform type identifier (UTI) of the image. public var uti: String + + public init(url: URL, identifier: String? = nil, uti: String) { + self.url = url + self.identifier = identifier + self.uti = uti + } } diff --git a/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift b/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift index 8af4037..776d1c2 100644 --- a/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift +++ b/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift @@ -16,16 +16,34 @@ extension URLImageFileStoreType { func getImagePublisher(_ keys: [URLImageKey], maxPixelSize: CGSize?) -> AnyPublisher { Future { promise in + nonisolated(unsafe) let promised = promise self.getImage(keys) { location -> TransientImage in guard let transientImage = TransientImage(location: location, maxPixelSize: maxPixelSize) else { throw URLImageError.decode } - return transientImage } completion: { result in - promise(result) + promised(result) } }.eraseToAnyPublisher() } + + func getImage(_ keys: [URLImageKey], maxPixelSize: CGSize?) async throws -> TransientImage? { + try await withCheckedThrowingContinuation { continuation in + getImage(keys) { location -> TransientImage in + guard let transientImage = TransientImage(location: location, maxPixelSize: maxPixelSize) else { + throw URLImageError.decode + } + return transientImage + } completion: { result in + switch result { + case .success(let image): + continuation.resume(returning: image) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } } diff --git a/Sources/URLImage/Store/URLImageFileStoreType.swift b/Sources/URLImage/Store/URLImageFileStoreType.swift index 7ff0d6b..bd6a97c 100644 --- a/Sources/URLImage/Store/URLImageFileStoreType.swift +++ b/Sources/URLImage/Store/URLImageFileStoreType.swift @@ -17,8 +17,8 @@ public protocol URLImageFileStoreType: URLImageStoreType { /// - keys: An array of keys used to lookup the image /// - open: A closure used to open the image file by delegating its decoding to the calling routine func getImage(_ keys: [URLImageKey], - open: @escaping (_ location: URL) throws -> T?, - completion: @escaping (_ result: Result) -> Void) + open: @Sendable @escaping (_ location: URL) throws -> T?, + completion: @Sendable @escaping (_ result: Result) -> Void) /// Write image data to the store. func storeImageData(_ data: Data, info: URLImageStoreInfo) @@ -26,3 +26,23 @@ public protocol URLImageFileStoreType: URLImageStoreType { /// Move image file from the temporary location to the store. func moveImageFile(from location: URL, info: URLImageStoreInfo) } + + +/// Type that declares requirements for a persistent store to store image files. +@available(macOS 10.15.0, iOS 13.0, *) +public protocol URLImageFileStoreType_Concurrency: URLImageStoreType_Concurrency { + + /// Get image from the strore. + /// + /// - parameters: + /// - keys: An array of keys used to lookup the image + /// - open: A closure used to open the image file by delegating its decoding to the calling routine + func getImage(_ keys: [URLImageKey], open: @escaping (_ location: URL) async throws -> T?) async throws -> T? + + /// Write image data to the store. + func storeImageData(_ data: Data, info: URLImageStoreInfo) async + + /// Move image file from the temporary location to the store. + func moveImageFile(from location: URL, info: URLImageStoreInfo) async +} + diff --git a/Sources/URLImage/Store/URLImageInMemoryStoreType.swift b/Sources/URLImage/Store/URLImageInMemoryStoreType.swift index 45013de..e90ee85 100644 --- a/Sources/URLImage/Store/URLImageInMemoryStoreType.swift +++ b/Sources/URLImage/Store/URLImageInMemoryStoreType.swift @@ -21,12 +21,13 @@ public protocol URLImageInMemoryStoreType: URLImageStoreType { @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension URLImageInMemoryStoreType { - + @MainActor func getImage(_ identifier: String) -> CGImage? { let transientImage: TransientImage? = getImage([ .identifier(identifier) ]) return transientImage?.cgImage } + @MainActor func getImage(_ url: URL) -> CGImage? { let transientImage: TransientImage? = getImage([ .url(url) ]) return transientImage?.cgImage diff --git a/Sources/URLImage/Store/URLImageStoreType.swift b/Sources/URLImage/Store/URLImageStoreType.swift index 7a9bad8..8a8cc4b 100644 --- a/Sources/URLImage/Store/URLImageStoreType.swift +++ b/Sources/URLImage/Store/URLImageStoreType.swift @@ -17,3 +17,13 @@ public protocol URLImageStoreType { func removeImageWithIdentifier(_ identifier: String) } + +@available(macOS 10.15.0, iOS 13.0, *) +public protocol URLImageStoreType_Concurrency { + + func removeAllImages() async + + func removeImageWithURL(_ url: URL) async + + func removeImageWithIdentifier(_ identifier: String) async +} diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 7529222..b5f26cc 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -32,14 +32,14 @@ public struct URLImage : View where Empty : let identifier: String? public var body: some View { - let remoteImage = service.makeRemoteImage(url: url, identifier: identifier, options: urlImageOptions) - - return RemoteImageView(remoteImage: remoteImage, - loadOptions: urlImageOptions.loadOptions, - empty: empty, - inProgress: inProgress, - failure: failure, - content: content) + InstalledRemoteView(service: service, url: url, identifier: identifier, options: urlImageOptions) { remoteImage in + RemoteImageView(remoteImage: remoteImage, + loadOptions: urlImageOptions.loadOptions, + empty: empty, + inProgress: inProgress, + failure: failure, + content: content) + } } private let empty: () -> Empty @@ -64,7 +64,6 @@ public struct URLImage : View where Empty : } } - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension URLImage { @@ -285,3 +284,41 @@ public extension URLImage where InProgress == Content, content: { image in content(.success(image)) }) } } + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +struct InstalledRemoteView: View { + var service: URLImageService + var content: (RemoteImage) -> Content + var url: URL + var identifier: String? + var options: URLImageOptions + @State private var remoteImage: RemoteImage? + + init(service: URLImageService, url: URL, identifier: String?, options: URLImageOptions, @ViewBuilder content: @escaping (RemoteImage) -> Content) { + self.service = service + self.content = content + self.url = url + self.identifier = identifier + self.options = options + } + + var body: some View { + if let remoteImge = remoteImage { + content(remoteImge) + } else { + Color.clear.backport.task { + await inital() + } + } + } + + private func inital() async { + let image = service.makeRemoteImage(url: url, identifier: identifier, options: options) + remoteImage = image + if options.loadOptions.contains(.loadImmediately) { + await image.load() + } + } +} + + diff --git a/Sources/URLImage/URLImageOptions.swift b/Sources/URLImage/URLImageOptions.swift index 0b28d72..42b0c24 100644 --- a/Sources/URLImage/URLImageOptions.swift +++ b/Sources/URLImage/URLImageOptions.swift @@ -12,10 +12,10 @@ import DownloadManager /// Options to control how the image is downloaded and stored @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct URLImageOptions { +public struct URLImageOptions: Sendable { /// The `FetchPolicy` allows to choose between returning stored image or downloading the remote one. - public enum FetchPolicy: Hashable { + public enum FetchPolicy: Hashable, Sendable { /// Return an image from the store or download it /// @@ -27,7 +27,7 @@ public struct URLImageOptions { } /// Controls some aspects of download process - public struct LoadOptions: OptionSet, Hashable { + public struct LoadOptions: OptionSet, Hashable, Sendable { public let rawValue: Int @@ -55,15 +55,19 @@ public struct URLImageOptions { /// Maximum size of a decoded image in pixels. If this property is not specified, the width and height of a decoded is not limited and may be as big as the image itself. public var maxPixelSize: CGSize? + + public var loadingAnimated: Bool public init(fetchPolicy: FetchPolicy = .returnStoreElseLoad(downloadDelay: 0.25), loadOptions: LoadOptions = [ .loadImmediately ], + loadingAnimated: Bool = true, urlRequestConfiguration: Download.URLRequestConfiguration? = nil, maxPixelSize: CGSize? = nil) { self.fetchPolicy = fetchPolicy self.loadOptions = loadOptions self.urlRequestConfiguration = urlRequestConfiguration self.maxPixelSize = maxPixelSize + self.loadingAnimated = loadingAnimated } } @@ -75,6 +79,7 @@ extension URLImageOptions: Hashable { hasher.combine(fetchPolicy) hasher.combine(loadOptions) hasher.combine(urlRequestConfiguration) + hasher.combine(loadingAnimated) if let maxPixelSize = maxPixelSize { hasher.combine(maxPixelSize.width) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift new file mode 100644 index 0000000..5b2a7e2 --- /dev/null +++ b/Sources/URLImage/Views/GIFImage.swift @@ -0,0 +1,242 @@ +// +// SwiftUIView.swift +// +// +// Created by sonoma on 4/21/24. +// + +import SwiftUI +import Model + +#if os(iOS) || os(watchOS) +public typealias PlatformView = UIView +public typealias PlatformViewRepresentable = UIViewRepresentable +public typealias PlatformImage = UIImage +public typealias PlatformImageView = UIImageView +#elseif os(macOS) +public typealias PlatformView = NSView +public typealias PlatformImage = NSImage +public typealias PlatformImageView = NSImageView +public typealias PlatformViewRepresentable = NSViewRepresentable +#endif + +@available(macOS 12.0, iOS 15.0, *) +public struct GIFImage : View where Empty : View, + InProgress : View, + Failure : View, + Content : View { + @Environment(\.urlImageService) var urlImageService + @Environment(\.urlImageOptions) var options + + var url: URL + + private let empty: () -> Empty + private let inProgress: (_ progress: Float?) -> InProgress + private let failure: (_ error: Error, _ retry: @escaping () -> Void) -> Failure + private let content: (_ image: GIFImageView) -> Content + + public init(_ url: URL, + @ViewBuilder empty: @escaping () -> Empty, + @ViewBuilder inProgress: @escaping (_ progress: Float?) -> InProgress, + @ViewBuilder failure: @escaping (_ error: Error, _ retry: @escaping () -> Void) -> Failure, + @ViewBuilder content: @escaping (_ transientImage: GIFImageView) -> Content) { + + self.url = url + self.empty = empty + self.inProgress = inProgress + self.failure = failure + self.content = content + } + + public var body: some View { + InstalledRemoteView(service: urlImageService, url: url, identifier: nil, options: options) { remoteImage in + RemoteGIFImageView(remoteImage: remoteImage, + loadOptions: options.loadOptions, + empty: empty, + inProgress: inProgress, + failure: failure, + content: content) + } + } +} + +@available(macOS 11.0, iOS 14.0, *) +public struct GIFImageView: View { + var image: PlatformImage + @Environment(\.imageConfigures) var imageConfigures + + init(image: PlatformImage) { + self.image = image + } + + public var body: some View { + if imageConfigures.resizeble, let aspectRatio = imageConfigures.aspectRatio { + GIFRepresentView(image: image) + .aspectRatio(aspectRatio, contentMode: imageConfigures.contentMode == .fit ? .fit:.fill) + } else { + GIFRepresentView(image: image) + .frame(width: image.size.width, height: image.size.height) + } + } +} + +public extension View { + func aspectResizeble(ratio: CGFloat, contentMode: ContentMode = .fit) -> some View { + self.environment(\.imageConfigures, ImageConfigures(aspectRatio: ratio, contentMode: contentMode, resizeble: true)) + } +} + +@available(macOS 11.0, iOS 14.0, *) +struct GIFRepresentView: PlatformViewRepresentable { + var image: PlatformImage + @Environment(\.imageConfigures) var imageConfigures + +#if os(iOS) || os(watchOS) + public func makeUIView(context: Context) -> UIGIFImage { + UIGIFImage(source: image) + } + + public func updateUIView(_ uiView: UIGIFImage, context: Context) { + + } +#elseif os(macOS) + public func makeNSView(context: Context) -> UIGIFImage { + UIGIFImage(source: image) + } + + public func updateNSView(_ nsView: NSViewType, context: Context) { + + } +#endif +} + +public final class UIGIFImage: PlatformView { + let imageView = PlatformImageView() + var source: PlatformImage? + var imageConfigures: ImageConfigures? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + convenience init(source: PlatformImage) { + self.init() + self.source = source + initView() + } + +#if os(iOS) || os(watchOS) + public override func layoutSubviews() { + super.layoutSubviews() + imageView.frame = bounds + addSubview(imageView) + } +#elseif os(macOS) + public override func layout() { + super.layout() + imageView.frame = bounds + addSubview(imageView) + } +#endif + + private func initView() { +#if os(iOS) || os(watchOS) + imageView.contentMode = imageConfigures?.contentMode == .fit ? .scaleAspectFit:.scaleAspectFill + imageView.image = source +#elseif os(macOS) + imageView.imageScaling = imageConfigures?.contentMode == .fit ? .scaleAxesIndependently:.scaleProportionallyUpOrDown + imageView.image = source + imageView.animates = true +#endif + } +} + + +public struct Scenes: Sendable { + public init() { + + } + +#if os(iOS) || os(watchOS) + @MainActor func keyScreen() -> UIScreen { + UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { + $0.activationState == .foregroundActive + })?.screen ?? UIScreen.main + } + + @MainActor public func nativeScale() -> CGFloat { + keyScreen().nativeScale + } +#elseif os(macOS) + public func nativeScale() -> CGFloat { + NSScreen.main?.backingScaleFactor ?? 1 + } +#endif +} + +public enum ContentMode: Sendable { + case fit + case fill +} + +struct ImageConfigures: Sendable { + var aspectRatio: CGFloat? + var contentMode: ContentMode + var resizeble: Bool + + func aspectRatio(_ aspectRatio: CGFloat) -> ImageConfigures { + var configures = self + configures.aspectRatio = aspectRatio + return configures + } + + func contentMode(_ contentMode: ContentMode) -> ImageConfigures { + var configures = self + configures.contentMode = contentMode + return configures + } +} + +extension EnvironmentValues { + @Entry var imageConfigures: ImageConfigures = ImageConfigures(aspectRatio: nil, contentMode: .fit, resizeble: false) +} + +//@available(iOS 13.0, *) +//struct GIFImageTest: View { +// @State private var imageData: Data? = nil +// +// var body: some View { +// VStack { +// GIFImage(name: "preview") +// .frame(height: 300) +// if let data = imageData { +// GIFImage(data: data) +// .frame(width: 300) +// } else { +// Text("Loading...") +// .onAppear(perform: loadData) +// } +// } +// } +// +// private func loadData() { +// let task = URLSession.shared.dataTask(with: URL(string: "https://github.com/globulus/swiftui-webview/raw/main/Images/preview_macos.gif?raw=true")!) { data, response, error in +// imageData = data +// } +// task.resume() +// } +//} +// +// +//struct GIFImage_Previews: PreviewProvider { +// static var previews: some View { +// GIFImageTest() +// } +//} + diff --git a/Sources/URLImage/Views/RemoteGIFImageView.swift b/Sources/URLImage/Views/RemoteGIFImageView.swift new file mode 100644 index 0000000..4080f35 --- /dev/null +++ b/Sources/URLImage/Views/RemoteGIFImageView.swift @@ -0,0 +1,255 @@ +// +// SwiftUIView.swift +// +// +// Created by sonoma on 4/24/24. +// + +import SwiftUI +import Model + +@available(macOS 12.0, iOS 15.0, tvOS 14.0, watchOS 7.0, *) +struct RemoteGIFImageView : View where Empty : View, + InProgress : View, + Failure : View, + Content : View { + @ObservedObject private var remoteImage: RemoteImage + @Environment(\.urlImageService) var urlImageService + @Environment(\.urlImageOptions) var options + @State private var image: PlatformImage? + @State var animateState: RemoteImageLoadingState = .initial + + let loadOptions: URLImageOptions.LoadOptions + + let empty: () -> Empty + let inProgress: (_ progress: Float?) -> InProgress + let failure: (_ error: Error, _ retry: @escaping () -> Void) -> Failure + let content: (_ value: GIFImageView) -> Content + + init(remoteImage: RemoteImage, + loadOptions: URLImageOptions.LoadOptions, + @ViewBuilder empty: @escaping () -> Empty, + @ViewBuilder inProgress: @escaping (_ progress: Float?) -> InProgress, + @ViewBuilder failure: @escaping (_ error: Error, _ retry: @escaping () -> Void) -> Failure, + @ViewBuilder content: @escaping (_ value: GIFImageView) -> Content) { + + self.remoteImage = remoteImage + self.loadOptions = loadOptions + + self.empty = empty + self.inProgress = inProgress + self.failure = failure + self.content = content + +// if loadOptions.contains(.loadImmediately) { +// remoteImage.load() +// } + } + + var body: some View { + ZStack { + switch animateState { + case .initial: + empty() + + case .inProgress(let progress): + inProgress(progress) + + case .success(_): + if let image = image { + content( + GIFImageView(image: image) + ) + } else { + inProgress(1.0) + } + case .failure(let error): + failure(error, loadRemoteImage) + } + } + .onAppear { + if loadOptions.contains(.loadOnAppear), !remoteImage.slowLoadingState.value.isSuccess { + loadRemoteImage() + } + } + .onDisappear { + if loadOptions.contains(.cancelOnDisappear) { + remoteImage.cancel() + } + } + .onReceive(remoteImage.slowLoadingState) { newValue in + Task { + await prepare(newValue) + } + animateState = newValue + } + } + + private func prepare(_ state: RemoteImage.LoadingState) async { + if case .success(_) = state { + if let image = await loadMemoryStore(options.maxPixelSize) { + self.image = image + return + } + await load(options.maxPixelSize) + } + } + + private func loadRemoteImage() { + let remote = remoteImage + Task { + await remote.load() + } + } + +// private func transientImage(_ pass: TransientImage) -> PlatformImage { +// if options.maxPixelSize == nil { +//#if os(macOS) +// return gifImage(pass) ?? PlatformImage(cgImage: pass.cgImage, size: pass.info.size) +//#else +// return gifImage(pass) ?? PlatformImage(cgImage: pass.cgImage) +//#endif +// } +// let transient = TransientImage(decoder: pass.proxy.decoder, presentation: pass.presentation, maxPixelSize: options.maxPixelSize) ?? pass +// +//#if os(macOS) +// return gifImage(transient) ?? PlatformImage(cgImage: pass.cgImage, size: pass.info.size) +//#else +// return gifImage(transient) ?? PlatformImage(cgImage: pass.cgImage) +//#endif +// } + + private func load(_ maxPixelSize: CGSize?) async { + guard let fileStore = urlImageService.fileStore else { + print("fileStore missing") + return + } + + do { + let value = try await fileStore.getImage([.url(remoteImage.download.url)], maxPixelSize: maxPixelSize) + guard let value else { + return + } + let data = await gif(value, maxSize: options.maxPixelSize) + image = data + } catch { + print("retrive image with \(remoteImage.download.url) failed. \(error)") + } + } + + private func loadMemoryStore(_ maxPixelSize: CGSize?) async -> PlatformImage? { + guard let memoryStore = urlImageService.inMemoryStore else { + print("memory store missing") + return nil + } + + guard let value: TransientImage = memoryStore.getImage([.url(remoteImage.download.url)]) else { + print("\(remoteImage.download.url) not cached in memory store") + return nil + } + + return await gif(value, maxSize: options.maxPixelSize) + } +} + +#if os(iOS) || os(watchOS) +@available(iOS 14.0, *) +fileprivate func gifImage(_ image: TransientImage, maxSize: CGSize?) async -> PlatformImage? { + let decoder = image.proxy.decoder + let count = decoder.frameCount + var delays = [Int]() + for delay in 0.. PlatformImage? { + switch source.presentation { + case .data(let data): + return PlatformImage(data: data) + case .file(let path): + let image = PlatformImage(contentsOfFile: path) + if let image { + print("cache image file \(image) load from \(path)") + } else { + print("cache image file nil load from \(path)") + } + return image + } +} + +extension NSImage: @unchecked Sendable { + +} +#endif + +//fileprivate func gifCacheImageData(_ source: Imaged) -> PlatformImage? { +// +//} + +@available(macOS 11.0, iOS 14.0, *) +fileprivate func gif(_ source: TransientImage, maxSize: CGSize?) async -> PlatformImage? { + await gifImage(source, maxSize: maxSize) +} + +fileprivate func gcd(_ a: Int, _ b: Int) -> Int { + let absB = abs(b) + let r = abs(a) % absB + if r != 0 { + return gcd(absB, r) + } else { + return absB + } +} + +fileprivate func delayForImage(at index: Int, source: CGImageSource) -> Double { + var delay = 0.1 + + // Get dictionaries + let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) + let gifPropertiesPointer = UnsafeMutablePointer.allocate(capacity: 0) + if CFDictionaryGetValueIfPresent(cfProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque(), gifPropertiesPointer) == false { + return delay + } + + let gifProperties:CFDictionary = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self) + + // Get delay time + var delayObject: AnyObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()), to: AnyObject.self) + if delayObject.doubleValue == 0 { + delayObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), to: AnyObject.self) + } + + delay = delayObject as! Double + + if delay < 0.1 { + delay = 0.1 // Make sure they're not too fast + } + + return delay +} + + +//#Preview { +// RemoteGIFImageView() +//} diff --git a/Sources/URLImage/Views/RemoteImageView.swift b/Sources/URLImage/Views/RemoteImageView.swift index 52b1fff..d21a38c 100644 --- a/Sources/URLImage/Views/RemoteImageView.swift +++ b/Sources/URLImage/Views/RemoteImageView.swift @@ -15,7 +15,8 @@ struct RemoteImageView : View where Empty : InProgress : View, Failure : View, Content : View { - @ObservedObject private(set) var remoteImage: RemoteImage + @ObservedObject var remoteImage: RemoteImage + @Environment(\.urlImageOptions) var urlImageOptions let loadOptions: URLImageOptions.LoadOptions @@ -23,6 +24,9 @@ struct RemoteImageView : View where Empty : let inProgress: (_ progress: Float?) -> InProgress let failure: (_ error: Error, _ retry: @escaping () -> Void) -> Failure let content: (_ value: TransientImage) -> Content + + @Namespace var namespace + @State var animateState: RemoteImageLoadingState = .initial init(remoteImage: RemoteImage, loadOptions: URLImageOptions.LoadOptions, @@ -39,32 +43,33 @@ struct RemoteImageView : View where Empty : self.failure = failure self.content = content - if loadOptions.contains(.loadImmediately) { - remoteImage.load() - } +// if loadOptions.contains(.loadImmediately), !remoteImage.loadingState.isSuccess { +// remoteImage.load() +// } } - + var body: some View { ZStack { - switch remoteImage.loadingState { - case .initial: - empty() - - case .inProgress(let progress): - inProgress(progress) - - case .success(let value): - content(value) - - case .failure(let error): - failure(error) { - remoteImage.load() - } + switch animateState { + case .initial: + empty() + .matchedGeometryEffect(id: remoteImage.download.url, in: namespace) + case .inProgress(let progress): + inProgress(progress) + .matchedGeometryEffect(id: remoteImage.download.url, in: namespace) + case .success(let value): + content(value) + .matchedGeometryEffect(id: remoteImage.download.url, in: namespace) + case .failure(let error): + failure(error) { + loadRemoteImage() + } + .matchedGeometryEffect(id: remoteImage.download.url, in: namespace) } } .onAppear { - if loadOptions.contains(.loadOnAppear) { - remoteImage.load() + if loadOptions.contains(.loadOnAppear), !remoteImage.slowLoadingState.value.isSuccess { + loadRemoteImage() } } .onDisappear { @@ -72,5 +77,28 @@ struct RemoteImageView : View where Empty : remoteImage.cancel() } } + .onReceive(remoteImage.slowLoadingState) { newValue in + guard urlImageOptions.loadingAnimated else { + animateState = newValue + return + } + + withAnimation(.smooth) { + animateState = newValue + } + } + } + + private func loadRemoteImage() { + let remote = remoteImage + Task { + await withTaskCancellationHandler(operation: { + await remote.load() + }, onCancel: { + Task { + await remote.cancel() + } + }) + } } } diff --git a/Sources/URLImageStore/URLImageFileStore.swift b/Sources/URLImageStore/URLImageFileStore.swift index 9411330..681a90a 100644 --- a/Sources/URLImageStore/URLImageFileStore.swift +++ b/Sources/URLImageStore/URLImageFileStore.swift @@ -14,7 +14,7 @@ import ImageDecoder @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public final class URLImageFileStore { +public final class URLImageFileStore: Sendable { let fileIndex: FileIndex @@ -35,26 +35,26 @@ public final class URLImageFileStore { public func getImage(_ identifier: String, maxPixelSize: CGSize? = nil, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ image: CGImage?) -> Void) { + completion: @Sendable @escaping (_ image: CGImage?) -> Void) { getImage([ .identifier(identifier) ], maxPixelSize: maxPixelSize, completionQueue: completionQueue, completion: completion) } public func getImage(_ url: URL, maxPixelSize: CGSize? = nil, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ image: CGImage?) -> Void) { + completion: @Sendable @escaping (_ image: CGImage?) -> Void) { getImage([ .url(url) ], maxPixelSize: maxPixelSize, completionQueue: completionQueue, completion: completion) } public func getImageLocation(_ identifier: String, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ location: URL?) -> Void) { + completion: @Sendable @escaping (_ location: URL?) -> Void) { getImageLocation([ .identifier(identifier) ], completionQueue: completionQueue, completion: completion) } public func getImageLocation(_ url: URL, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ location: URL?) -> Void) { + completion: @Sendable @escaping (_ location: URL?) -> Void) { getImageLocation([ .url(url) ], completionQueue: completionQueue, completion: completion) } @@ -126,7 +126,7 @@ public final class URLImageFileStore { private func getImageLocation(_ keys: [URLImageKey], completionQueue: DispatchQueue? = nil, - completion: @escaping (_ location: URL?) -> Void) { + completion: @Sendable @escaping (_ location: URL?) -> Void) { fileIndexQueue.async { [weak self] in guard let self = self else { @@ -165,36 +165,35 @@ public final class URLImageFileStore { private func getImage(_ keys: [URLImageKey], maxPixelSize: CGSize? = nil, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ image: CGImage?) -> Void) { + completion: @Sendable @escaping (_ image: CGImage?) -> Void) { getImage(keys, open: { location -> CGImage? in - guard let decoder = ImageDecoder(url: location) else { - return nil - } - - if let sizeForDrawing = maxPixelSize { - let decodingOptions = ImageDecoder.DecodingOptions(mode: .asynchronous, sizeForDrawing: sizeForDrawing) - return decoder.createFrameImage(at: 0, decodingOptions: decodingOptions)! - } else { - return decoder.createFrameImage(at: 0)! - } - }, - completion: { result in - let queue = completionQueue ?? DispatchQueue.global() - - switch result { - - case .success(let image): - queue.async { - completion(image) - } - - case .failure: - queue.async { - completion(nil) - } - } - }) + guard let decoder = ImageDecoder(url: location) else { + return nil + } + + if let sizeForDrawing = maxPixelSize { + let decodingOptions = ImageDecoder.DecodingOptions(mode: .asynchronous, sizeForDrawing: sizeForDrawing) + return decoder.createFrameImage(at: 0, decodingOptions: decodingOptions)! + } else { + return decoder.createFrameImage(at: 0)! + } + }, completion: { result in + let queue = completionQueue ?? DispatchQueue.global() + + switch result { + + case .success(let image): + queue.async { + completion(image) + } + + case .failure: + queue.async { + completion(nil) + } + } + }) } } @@ -245,8 +244,8 @@ extension URLImageFileStore: URLImageFileStoreType { } public func getImage(_ keys: [URLImageKey], - open: @escaping (_ location: URL) throws -> T?, - completion: @escaping (_ result: Result) -> Void) { + open: @Sendable @escaping (_ location: URL) throws -> T?, + completion: @Sendable @escaping (_ result: Result) -> Void) { getImageLocation(keys, completionQueue: decodeQueue) { [weak self] location in guard let _ = self else { // Just a sanity check if the cache object is still exists diff --git a/Sources/URLImageStore/URLImageFileStore_Concurrency.swift b/Sources/URLImageStore/URLImageFileStore_Concurrency.swift new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Sources/URLImageStore/URLImageFileStore_Concurrency.swift @@ -0,0 +1 @@ +