import Foundation import SwiftUI #if canImport(UIKit) import UIKit #elseif canImport(AppKit) import AppKit #endif struct CachedRemoteImage: View { let imageURL: String? let ttl: TimeInterval let placeholder: Placeholder @StateObject var loader = CachedRemoteImageLoader() init( imageURL: String?, ttl: TimeInterval = AppCacheTTL.twoHours, @ViewBuilder placeholder: () -> Placeholder ) { self.imageURL = imageURL self.ttl = ttl self.placeholder = placeholder() } var body: some View { Group { #if canImport(UIKit) if let uiImage = loader.uiImage { Image(uiImage: uiImage) .resizable() .scaledToFill() } else { placeholder } #elseif canImport(AppKit) if let nsImage = loader.nsImage { Image(nsImage: nsImage) .resizable() .scaledToFill() } else { placeholder } #else if let imageURL, let url = URL(string: imageURL) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() default: placeholder } } } else { placeholder } #endif } .onAppear { loader.load(imageURL: imageURL, ttl: ttl) } .onChange(of: imageURL) { _, newValue in loader.load(imageURL: newValue, ttl: ttl) } } } @MainActor final class CachedRemoteImageLoader: ObservableObject { #if canImport(UIKit) @Published var uiImage: UIImage? #elseif canImport(AppKit) @Published var nsImage: NSImage? #endif private var currentKey: String? private var task: Task? deinit { task?.cancel() } func load(imageURL: String?, ttl: TimeInterval) { let normalized = Self.normalizeImageSource(imageURL) let key = normalized ?? "" guard currentKey != key else { return } currentKey = key task?.cancel() #if canImport(UIKit) uiImage = nil #elseif canImport(AppKit) nsImage = nil #endif guard let normalized, normalized.isEmpty == false else { return } #if canImport(UIKit) || canImport(AppKit) let dataCacheKey = Self.dataURLCacheKey(normalized) if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) { #if canImport(UIKit) uiImage = cachedDataImage #elseif canImport(AppKit) nsImage = cachedDataImage #endif return } if let image = Self.imageFromDataURL(normalized) { AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl) #if canImport(UIKit) uiImage = image #elseif canImport(AppKit) nsImage = image #endif return } #endif guard let url = URL(string: normalized) else { return } task = Task { [weak self] in #if canImport(UIKit) || canImport(AppKit) let image = await AppImageCache.shared.image(for: url, ttl: ttl) guard Task.isCancelled == false else { return } await MainActor.run { #if canImport(UIKit) self?.uiImage = image #elseif canImport(AppKit) self?.nsImage = image #endif } #endif } } private static func normalizeImageSource(_ value: String?) -> String? { ImageSourceResolver.resolve(value) } private static func dataURLCacheKey(_ source: String) -> String { let head = String(source.prefix(48)) let tail = String(source.suffix(48)) return "data-image:\(source.count):\(head):\(tail)" } #if canImport(UIKit) || canImport(AppKit) private static func imageFromDataURL(_ source: String) -> PlatformImage? { let lower = source.lowercased() guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil } let header = String(source[..