-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
Copy pathFaviconService.swift
111 lines (94 loc) · 3.65 KB
/
FaviconService.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import UIKit
/// Fetches URLs for favicons for sites.
public actor FaviconService {
public static let shared = FaviconService()
private nonisolated let cache = FaviconCache()
private let session = URLSession(configuration: {
let configuration = URLSessionConfiguration.default
configuration.urlCache = nil
return configuration
}())
private var tasks: [URL: FaviconTask] = [:]
nonisolated public func cachedFavicon(forURL siteURL: URL) -> URL? {
cache.cachedFavicon(forURL: siteURL)
}
/// Returns a favicon URL for the given site.
public func favicon(forURL siteURL: URL) async throws -> URL {
if let faviconURL = cache.cachedFavicon(forURL: siteURL) {
return faviconURL
}
let faviconURL = try await _favicon(forURL: siteURL)
cache.storeCachedFaviconURL(faviconURL, forURL: siteURL)
return faviconURL
}
private func _favicon(forURL siteURL: URL) async throws -> URL {
let task = tasks[siteURL] ?? FaviconTask { [session] in
let (data, response) = try await session.data(from: siteURL)
try validate(response: response)
return await makeFavicon(from: data, siteURL: siteURL)
}
let subscriptionID = UUID()
task.subscriptions.insert(subscriptionID)
tasks[siteURL] = task
return try await withTaskCancellationHandler {
try await task.task.value
} onCancel: {
Task {
await self.unsubscribe(subscriptionID, key: siteURL)
}
}
}
private func unsubscribe(_ subscriptionID: UUID, key: URL) {
guard let task = tasks[key],
task.subscriptions.remove(subscriptionID) != nil,
task.subscriptions.isEmpty else {
return
}
task.task.cancel()
tasks[key] = nil
}
}
public enum FaviconError: Error, Sendable {
case unacceptableStatusCode(_ code: Int)
}
private final class FaviconCache: @unchecked Sendable {
private let cache = NSCache<AnyObject, AnyObject>()
func cachedFavicon(forURL siteURL: URL) -> URL? {
cache.object(forKey: siteURL as NSURL) as? URL
}
func storeCachedFaviconURL(_ faviconURL: URL, forURL siteURL: URL) {
cache.setObject(faviconURL as NSURL, forKey: siteURL as NSURL)
}
}
private let regex: NSRegularExpression? = {
let pattern = "<link[^>]*rel=\"apple-touch-icon\"[^>]*href=\"([^\"]+)\"[^>]*>"
return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}()
private func makeFavicon(from data: Data, siteURL: URL) async -> URL {
let html = String(data: data, encoding: .utf8) ?? ""
let range = NSRange(location: 0, length: html.utf16.count)
if let match = regex?.firstMatch(in: html, options: [], range: range),
let matchRange = Range(match.range(at: 1), in: html),
let faviconURL = URL(string: String(html[matchRange]), relativeTo: siteURL) {
return faviconURL
}
// Fallback to standard favicon path. It has low quality, but
// it's better than nothing.
return siteURL.appendingPathComponent("favicon.icon")
}
private func validate(response: URLResponse) throws {
guard let response = response as? HTTPURLResponse else {
return
}
guard (200..<300).contains(response.statusCode) else {
throw FaviconError.unacceptableStatusCode(response.statusCode)
}
}
private final class FaviconTask {
var subscriptions = Set<UUID>()
var isCancelled = false
var task: Task<URL, Error>
init(_ closure: @escaping @Sendable () async throws -> URL) {
self.task = Task { try await closure() }
}
}