import Foundation import SwiftUI #if canImport(UIKit) import UIKit #elseif canImport(AppKit) import AppKit #endif enum ImageFitMode { /// Scale to cover the whole box, cropping whichever axis overflows. /// Default — matches historical behavior for every image in the app. case fill /// Scale so height always matches the box exactly; width follows the /// source's aspect ratio and gets cropped/gapped on the sides. Opt-in /// only — used by the Store Detail cover header. case heightFit } struct CachedRemoteImage: View { let imageURL: String? let ttl: TimeInterval let fitMode: ImageFitMode let placeholder: Placeholder #if canImport(UIKit) || canImport(AppKit) @StateObject var loader = CachedRemoteImageLoader() #endif init( imageURL: String?, ttl: TimeInterval = AppCacheTTL.twoHours, fitMode: ImageFitMode = .fill, @ViewBuilder placeholder: () -> Placeholder ) { self.imageURL = imageURL self.ttl = ttl self.fitMode = fitMode self.placeholder = placeholder() } var body: some View { GeometryReader { geometry in Group { #if canImport(UIKit) if let uiImage = loader.uiImage { rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size) } else { placeholder } #elseif canImport(AppKit) if let nsImage = loader.nsImage { rendered(image: Image(nsImage: nsImage), pixelSize: nsImage.size, in: geometry.size) } 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 } .frame(width: geometry.size.width, height: geometry.size.height) .clipped() } .onAppear { #if canImport(UIKit) || canImport(AppKit) loader.load(imageURL: imageURL, ttl: ttl) #endif } .onChange(of: imageURL) { _, newValue in #if canImport(UIKit) || canImport(AppKit) loader.load(imageURL: newValue, ttl: ttl) #endif } } #if canImport(UIKit) || canImport(AppKit) @ViewBuilder private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View { switch fitMode { case .fill: image .resizable() .scaledToFill() case .heightFit: let aspect = pixelSize.height > 0 ? pixelSize.width / pixelSize.height : 1 let renderWidth = containerSize.height * aspect image .resizable() .frame(width: renderWidth, height: containerSize.height) .frame(width: containerSize.width, height: containerSize.height) } } #endif } #if canImport(UIKit) || canImport(AppKit) @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[..