|
@@ -1,42 +1,290 @@
|
|
|
import AppKit
|
|
import AppKit
|
|
|
import SwiftUI
|
|
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: "&", with: "&")
|
|
|
|
|
+ .replacingOccurrences(of: "<", with: "<")
|
|
|
|
|
+ .replacingOccurrences(of: ">", 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 {
|
|
actor FaviconImageCache {
|
|
|
static let shared = FaviconImageCache()
|
|
static let shared = FaviconImageCache()
|
|
|
|
|
|
|
|
private var memory: [String: NSImage] = [:]
|
|
private var memory: [String: NSImage] = [:]
|
|
|
|
|
|
|
|
func image(for siteURL: URL) async -> 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
|
|
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 {
|
|
struct WebSiteFaviconView: View {
|
|
|
let webURL: URL
|
|
let webURL: URL
|
|
|
let size: CGFloat
|
|
let size: CGFloat
|
|
@@ -54,21 +302,24 @@ struct WebSiteFaviconView: View {
|
|
|
.interpolation(.high)
|
|
.interpolation(.high)
|
|
|
.scaledToFit()
|
|
.scaledToFit()
|
|
|
.padding(size * iconPaddingFactor)
|
|
.padding(size * iconPaddingFactor)
|
|
|
- } else if let asset = NSImage(named: assetIconName) {
|
|
|
|
|
|
|
+ } else if !assetIconName.isEmpty, let asset = NSImage(named: assetIconName) {
|
|
|
Image(nsImage: asset)
|
|
Image(nsImage: asset)
|
|
|
.resizable()
|
|
.resizable()
|
|
|
|
|
+ .interpolation(.high)
|
|
|
.scaledToFit()
|
|
.scaledToFit()
|
|
|
.padding(size * iconPaddingFactor)
|
|
.padding(size * iconPaddingFactor)
|
|
|
} else {
|
|
} else {
|
|
|
Image(systemName: fallbackSymbolName)
|
|
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)
|
|
.frame(width: size, height: size)
|
|
|
.task(id: webURL.absoluteString) {
|
|
.task(id: webURL.absoluteString) {
|
|
|
remoteImage = nil
|
|
remoteImage = nil
|
|
|
- if let img = await FaviconImageCache.shared.image(for: webURL) {
|
|
|
|
|
|
|
+ let img = await FaviconImageCache.shared.image(for: webURL)
|
|
|
|
|
+ await MainActor.run {
|
|
|
remoteImage = img
|
|
remoteImage = img
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|