|
|
@@ -0,0 +1,76 @@
|
|
|
+import AppKit
|
|
|
+import SwiftUI
|
|
|
+
|
|
|
+/// Fetches site icons via Google’s favicon service (domain-based), with in-memory caching.
|
|
|
+actor FaviconImageCache {
|
|
|
+ static let shared = FaviconImageCache()
|
|
|
+
|
|
|
+ private var memory: [String: NSImage] = [:]
|
|
|
+
|
|
|
+ func image(for siteURL: URL) async -> NSImage? {
|
|
|
+ guard let faviconURL = Self.faviconServiceURL(for: siteURL) else { return nil }
|
|
|
+ let key = faviconURL.absoluteString
|
|
|
+ if let cached = memory[key] { return cached }
|
|
|
+ do {
|
|
|
+ var request = URLRequest(url: faviconURL)
|
|
|
+ request.timeoutInterval = 20
|
|
|
+ request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/605.1.15", forHTTPHeaderField: "User-Agent")
|
|
|
+ let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
+ guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { return nil }
|
|
|
+ guard let image = NSImage(data: data), image.isValid else { return nil }
|
|
|
+ memory[key] = image
|
|
|
+ return image
|
|
|
+ } catch {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func faviconServiceURL(for siteURL: URL) -> URL? {
|
|
|
+ guard let host = siteURL.host?.lowercased(), !host.isEmpty else { return nil }
|
|
|
+ var components = URLComponents(string: "https://www.google.com/s2/favicons")!
|
|
|
+ components.queryItems = [
|
|
|
+ URLQueryItem(name: "domain", value: host),
|
|
|
+ URLQueryItem(name: "sz", value: "128"),
|
|
|
+ ]
|
|
|
+ return components.url
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// Shows a remote favicon when `webURL` is set; uses bundled asset then SF Symbol while loading or on failure.
|
|
|
+struct WebSiteFaviconView: View {
|
|
|
+ let webURL: URL
|
|
|
+ let size: CGFloat
|
|
|
+ let assetIconName: String
|
|
|
+ let fallbackSymbolName: String
|
|
|
+ var iconPaddingFactor: CGFloat = 0.12
|
|
|
+
|
|
|
+ @State private var remoteImage: NSImage?
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Group {
|
|
|
+ if let remote = remoteImage {
|
|
|
+ Image(nsImage: remote)
|
|
|
+ .resizable()
|
|
|
+ .interpolation(.high)
|
|
|
+ .scaledToFit()
|
|
|
+ .padding(size * iconPaddingFactor)
|
|
|
+ } else if let asset = NSImage(named: assetIconName) {
|
|
|
+ Image(nsImage: asset)
|
|
|
+ .resizable()
|
|
|
+ .scaledToFit()
|
|
|
+ .padding(size * iconPaddingFactor)
|
|
|
+ } else {
|
|
|
+ Image(systemName: fallbackSymbolName)
|
|
|
+ .font(.system(size: size * 0.32, weight: .semibold))
|
|
|
+ .foregroundStyle(.white)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(width: size, height: size)
|
|
|
+ .task(id: webURL.absoluteString) {
|
|
|
+ remoteImage = nil
|
|
|
+ if let img = await FaviconImageCache.shared.image(for: webURL) {
|
|
|
+ remoteImage = img
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|