Parcourir la source

Improve dynamic favicon loading from live sites

Fetch origin paths and parse page HTML for link rel icons before third-party fallbacks; fix attribute parsing and dedupe discovered URLs.

Made-with: Cursor
huzaifahayat12 il y a 4 mois
Parent
commit
841b8597a3
1 fichiers modifiés avec 278 ajouts et 27 suppressions
  1. 278 27
      google_apps/WebSiteFaviconView.swift

+ 278 - 27
google_apps/WebSiteFaviconView.swift

@@ -1,42 +1,290 @@
 import AppKit
 import SwiftUI
 
-/// Fetches site icons via Google’s favicon service (domain-based), with in-memory caching.
+// MARK: - Fetching (dynamic: page HTML + origin paths, then API fallbacks)
+
+/// Resolves and downloads a site icon from the live website first, then public favicon services.
+enum WebSiteFaviconFetcher {
+    private static let userAgent =
+        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
+
+    /// Ordered attempts: same-origin files → HTML `<link rel="icon">` → third-party fallbacks.
+    static func loadImage(for siteURL: URL) async -> NSImage? {
+        guard let host = siteURL.host, !host.isEmpty else { return nil }
+
+        let scheme = (siteURL.scheme?.lowercased() == "http") ? "http" : "https"
+        let port = siteURL.port
+
+        // 1) Common paths on the same host (true “from site” URLs).
+        let rootPaths = [
+            "/favicon.ico",
+            "/favicon.png",
+            "/apple-touch-icon.png",
+            "/apple-touch-icon-precomposed.png",
+            "/icon.png",
+        ]
+        for path in rootPaths {
+            if let u = absoluteURL(scheme: scheme, host: host, port: port, path: path),
+               let image = await fetchBitmap(from: u) {
+                return image
+            }
+        }
+
+        // 2) Parse the homepage (or path) for `<link rel="icon" …>` etc.
+        let pageURLs = pageCandidates(for: siteURL, scheme: scheme, host: host, port: port)
+        for page in pageURLs {
+            if let discovered = await discoverIconURLs(fromHTMLPage: page) {
+                let sorted = sortIconCandidates(discovered)
+                var seenURLs = Set<String>()
+                for u in sorted {
+                    guard seenURLs.insert(u.absoluteString).inserted else { continue }
+                    if let image = await fetchBitmap(from: u) {
+                        return image
+                    }
+                }
+            }
+        }
+
+        // 3) Third-party resolvers (not “direct” from host, but reliable fallbacks).
+        let hostKey = host.lowercased()
+        if let u = googleFaviconURL(host: hostKey, size: 128), let image = await fetchBitmap(from: u) {
+            return image
+        }
+        if let u = URL(string: "https://icons.duckduckgo.com/ip3/\(hostKey).ico"),
+           let image = await fetchBitmap(from: u) {
+            return image
+        }
+        if let parent = registrableParentHost(hostKey), parent != hostKey {
+            if let u = googleFaviconURL(host: parent, size: 128), let image = await fetchBitmap(from: u) {
+                return image
+            }
+        }
+        if let u = URL(string: "https://unavatar.io/\(hostKey)?fallback=false"),
+           let image = await fetchBitmap(from: u) {
+            return image
+        }
+
+        return nil
+    }
+
+    // MARK: URL building
+
+    private static func absoluteURL(scheme: String, host: String, port: Int?, path: String) -> URL? {
+        var c = URLComponents()
+        c.scheme = scheme
+        c.host = host
+        c.port = port
+        c.path = path.hasPrefix("/") ? path : "/" + path
+        return c.url
+    }
+
+    private static func pageCandidates(for siteURL: URL, scheme: String, host: String, port: Int?) -> [URL] {
+        var list: [URL] = []
+        var seen = Set<String>()
+
+        func add(_ url: URL?) {
+            guard let url else { return }
+            let key = url.absoluteString
+            guard !seen.contains(key) else { return }
+            seen.insert(key)
+            list.append(url)
+        }
+
+        add(siteURL)
+        add(absoluteURL(scheme: scheme, host: host, port: port, path: "/"))
+
+        if scheme == "https",
+           let httpRoot = absoluteURL(scheme: "http", host: host, port: port, path: "/") {
+            add(httpRoot)
+        }
+
+        return list
+    }
+
+    private static func googleFaviconURL(host: String, size: Int) -> URL? {
+        var c = URLComponents(string: "https://www.google.com/s2/favicons")!
+        c.queryItems = [
+            URLQueryItem(name: "domain", value: host),
+            URLQueryItem(name: "sz", value: "\(size)"),
+        ]
+        return c.url
+    }
+
+    private static func registrableParentHost(_ host: String) -> String? {
+        let labels = host.split(separator: ".")
+        guard labels.count >= 3 else { return nil }
+        let lastTwo = labels.suffix(2).joined(separator: ".")
+        if ["co.uk", "com.au", "co.jp", "co.nz", "com.br"].contains(lastTwo) {
+            guard labels.count >= 4 else { return nil }
+            return labels.suffix(3).joined(separator: ".")
+        }
+        return labels.suffix(2).joined(separator: ".")
+    }
+
+    // MARK: HTML discovery
+
+    private struct IconCandidate {
+        let url: URL
+        let priority: Int
+    }
+
+    private static func discoverIconURLs(fromHTMLPage pageURL: URL) async -> [IconCandidate]? {
+        var request = URLRequest(url: pageURL)
+        request.timeoutInterval = 14
+        request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+        request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
+
+        guard let (data, response) = try? await URLSession.shared.data(for: request),
+              let http = response as? HTTPURLResponse,
+              (200...299).contains(http.statusCode) else {
+            return nil
+        }
+
+        let cap = min(data.count, 768_000)
+        let snippet = Data(data.prefix(cap))
+        guard let html = String(data: snippet, encoding: .utf8) ?? String(data: snippet, encoding: .isoLatin1) else {
+            return nil
+        }
+
+        var found: [IconCandidate] = []
+
+        let linkPattern = #"<link\s+([^>]*?)>"#
+        guard let linkRegex = try? NSRegularExpression(pattern: linkPattern, options: [.caseInsensitive]) else {
+            return nil
+        }
+
+        let range = NSRange(html.startIndex..., in: html)
+        linkRegex.enumerateMatches(in: html, options: [], range: range) { match, _, _ in
+            guard let match, match.range.location != NSNotFound,
+                  let innerRange = Range(match.range(at: 1), in: html) else { return }
+
+            let attrs = String(html[innerRange])
+            guard let rel = attributeValue(named: "rel", in: attrs)?.lowercased() else { return }
+            guard rel.contains("icon") || rel.contains("apple-touch") else { return }
+            guard let hrefRaw = attributeValue(named: "href", in: attrs) else { return }
+
+            let href = hrefRaw
+                .replacingOccurrences(of: "&amp;", with: "&")
+                .replacingOccurrences(of: "&lt;", with: "<")
+                .replacingOccurrences(of: "&gt;", with: ">")
+
+            guard let resolved = URL(string: href, relativeTo: pageURL)?.absoluteURL else { return }
+
+            let sizes = attributeValue(named: "sizes", in: attrs)?.lowercased() ?? ""
+            let px = largestDimension(fromSizes: sizes)
+            let priority = iconPriority(rel: rel, pixelHint: px)
+
+            found.append(IconCandidate(url: resolved, priority: priority))
+        }
+
+        return found.isEmpty ? nil : found
+    }
+
+    /// Pull `attr="value"` / `attr='value'` from a fragment of `<link …>`.
+    private static func attributeValue(named name: String, in fragment: String) -> String? {
+        let escaped = NSRegularExpression.escapedPattern(for: name)
+        let patterns = [
+            escaped + #"=["']([^"']*)["']"#,
+            escaped + #"=([^\s>]+)"#,
+        ]
+        for pattern in patterns {
+            guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { continue }
+            let range = NSRange(fragment.startIndex..., in: fragment)
+            guard let m = regex.firstMatch(in: fragment, range: range),
+                  m.numberOfRanges > 1,
+                  let r = Range(m.range(at: 1), in: fragment) else { continue }
+            let v = String(fragment[r]).trimmingCharacters(in: .whitespacesAndNewlines)
+            if !v.isEmpty { return v }
+        }
+        return nil
+    }
+
+    private static func largestDimension(fromSizes sizes: String) -> Int {
+        var best = 0
+        for part in sizes.split(separator: " ") {
+            let s = String(part)
+            let nums = s.split(separator: "x")
+            if nums.count == 2,
+               let a = Int(nums[0].filter(\.isNumber)),
+               let b = Int(nums[1].filter(\.isNumber)) {
+                best = max(best, a, b)
+            }
+        }
+        return best
+    }
+
+    private static func iconPriority(rel: String, pixelHint: Int) -> Int {
+        var score = pixelHint
+        if rel.contains("apple-touch") { score += 500 }
+        if rel.contains("shortcut") { score += 20 }
+        if rel == "icon" { score += 10 }
+        return score
+    }
+
+    private static func sortIconCandidates(_ items: [IconCandidate]) -> [URL] {
+        items.sorted { $0.priority > $1.priority }.map(\.url)
+    }
+
+    // MARK: Image fetch
+
+    private static func fetchBitmap(from url: URL) async -> NSImage? {
+        var request = URLRequest(url: url)
+        request.timeoutInterval = 18
+        request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+        request.setValue("image/*,*/*;q=0.8", forHTTPHeaderField: "Accept")
+
+        guard let (data, response) = try? await URLSession.shared.data(for: request),
+              let http = response as? HTTPURLResponse,
+              (200...299).contains(http.statusCode),
+              data.count > 24 else {
+            return nil
+        }
+
+        let mime = http.value(forHTTPHeaderField: "Content-Type")?.lowercased() ?? ""
+        if mime.contains("svg") {
+            return nil
+        }
+
+        guard let image = NSImage(data: data), !image.representations.isEmpty else {
+            return nil
+        }
+
+        let w = image.size.width
+        let h = image.size.height
+        guard w >= 8, h >= 8 else { return nil }
+
+        return image
+    }
+}
+
+// MARK: - Cache (per normalized host)
+
 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
+        guard let hostKey = Self.normalizedHostKey(for: siteURL) else { return nil }
+        if let cached = memory[hostKey] { return cached }
+
+        if let image = await WebSiteFaviconFetcher.loadImage(for: siteURL) {
+            memory[hostKey] = image
             return image
-        } catch {
-            return nil
         }
+        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
+    private static func normalizedHostKey(for siteURL: URL) -> String? {
+        guard var host = siteURL.host?.lowercased(), !host.isEmpty else { return nil }
+        if host.hasPrefix("www.") { host = String(host.dropFirst(4)) }
+        return host
     }
 }
 
-/// Shows a remote favicon when `webURL` is set; uses bundled asset then SF Symbol while loading or on failure.
+// MARK: - View
+
+/// Loads a remote favicon for `webURL`; optional bundled asset while loading or if fetch fails.
 struct WebSiteFaviconView: View {
     let webURL: URL
     let size: CGFloat
@@ -54,21 +302,24 @@ struct WebSiteFaviconView: View {
                     .interpolation(.high)
                     .scaledToFit()
                     .padding(size * iconPaddingFactor)
-            } else if let asset = NSImage(named: assetIconName) {
+            } else if !assetIconName.isEmpty, let asset = NSImage(named: assetIconName) {
                 Image(nsImage: asset)
                     .resizable()
+                    .interpolation(.high)
                     .scaledToFit()
                     .padding(size * iconPaddingFactor)
             } else {
                 Image(systemName: fallbackSymbolName)
-                    .font(.system(size: size * 0.32, weight: .semibold))
-                    .foregroundStyle(.white)
+                    .font(.system(size: size * 0.28, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.92))
+                    .padding(size * iconPaddingFactor)
             }
         }
         .frame(width: size, height: size)
         .task(id: webURL.absoluteString) {
             remoteImage = nil
-            if let img = await FaviconImageCache.shared.image(for: webURL) {
+            let img = await FaviconImageCache.shared.image(for: webURL)
+            await MainActor.run {
                 remoteImage = img
             }
         }