This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,216 @@
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<Placeholder: View>: 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<Void, Never>?
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[..<commaIndex]).lowercased()
guard header.contains(";base64") else { return nil }
let payloadStart = source.index(after: commaIndex)
let payload = String(source[payloadStart...])
.replacingOccurrences(of: "\\/", with: "/")
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil }
#if canImport(UIKit)
return UIImage(data: data)
#elseif canImport(AppKit)
return NSImage(data: data)
#else
return nil
#endif
}
#endif
}
#endif