Skip to content

Commit cc0d1f3

Browse files
keancrazytonyli
authored andcommitted
Fix regex for apple-touch-icon (#25025)
1 parent 6a50a4d commit cc0d1f3

File tree

2 files changed

+251
-15
lines changed

2 files changed

+251
-15
lines changed

Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public actor FaviconService {
3232
let task = tasks[siteURL] ?? FaviconTask { [session] in
3333
let (data, response) = try await session.data(from: siteURL)
3434
try validate(response: response)
35-
return await makeFavicon(from: data, siteURL: siteURL)
35+
return FaviconService.makeFavicon(from: data, siteURL: siteURL)
3636
}
3737
let subscriptionID = UUID()
3838
task.subscriptions.insert(subscriptionID)
@@ -55,6 +55,20 @@ public actor FaviconService {
5555
task.task.cancel()
5656
tasks[key] = nil
5757
}
58+
59+
/// Parses HTML data to extract the apple-touch-icon URL or falls back to /favicon.icon
60+
public static func makeFavicon(from data: Data, siteURL: URL) -> URL {
61+
let html = String(data: data, encoding: .utf8) ?? ""
62+
let range = NSRange(location: 0, length: html.utf16.count)
63+
if let match = regex?.firstMatch(in: html, options: [], range: range),
64+
let matchRange = Range(match.range(at: 1), in: html),
65+
let faviconURL = URL(string: String(html[matchRange]), relativeTo: siteURL) {
66+
return faviconURL
67+
}
68+
// Fallback to standard favicon path. It has low quality, but
69+
// it's better than nothing.
70+
return siteURL.appendingPathComponent("favicon.icon")
71+
}
5872
}
5973

6074
public enum FaviconError: Error, Sendable {
@@ -74,23 +88,10 @@ private final class FaviconCache: @unchecked Sendable {
7488
}
7589

7690
private let regex: NSRegularExpression? = {
77-
let pattern = "<link[^>]*rel=\"apple-touch-icon(?:-precomposed)\"[^>]*href=\"([^\"]+)\"[^>]*>"
91+
let pattern = "<link[^>]*rel=\"apple-touch-icon(?:-precomposed)?\"[^>]*href=\"([^\"]+)\"[^>]*>"
7892
return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
7993
}()
8094

81-
private func makeFavicon(from data: Data, siteURL: URL) async -> URL {
82-
let html = String(data: data, encoding: .utf8) ?? ""
83-
let range = NSRange(location: 0, length: html.utf16.count)
84-
if let match = regex?.firstMatch(in: html, options: [], range: range),
85-
let matchRange = Range(match.range(at: 1), in: html),
86-
let faviconURL = URL(string: String(html[matchRange]), relativeTo: siteURL) {
87-
return faviconURL
88-
}
89-
// Fallback to standard favicon path. It has low quality, but
90-
// it's better than nothing.
91-
return siteURL.appendingPathComponent("favicon.icon")
92-
}
93-
9495
private func validate(response: URLResponse) throws {
9596
guard let response = response as? HTTPURLResponse else {
9697
return
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import UIKit
2+
import Testing
3+
import AsyncImageKit
4+
5+
@Suite final class FaviconServiceTests {
6+
@Test func appleTouchIcon() throws {
7+
// GIVEN
8+
let siteURL = try #require(URL(string: "https://example.com"))
9+
let html = """
10+
<html>
11+
<head>
12+
<link rel="apple-touch-icon" href="/apple-icon.png">
13+
</head>
14+
</html>
15+
"""
16+
let data = Data(html.utf8)
17+
18+
// WHEN
19+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
20+
21+
// THEN
22+
#expect(faviconURL.absoluteString == "https://example.com/apple-icon.png")
23+
}
24+
25+
@Test func appleTouchIconPrecomposed() throws {
26+
// GIVEN
27+
let siteURL = try #require(URL(string: "https://example.com"))
28+
let html = """
29+
<html>
30+
<head>
31+
<link rel="apple-touch-icon-precomposed" href="/apple-icon-precomposed.png">
32+
</head>
33+
</html>
34+
"""
35+
let data = Data(html.utf8)
36+
37+
// WHEN
38+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
39+
40+
// THEN
41+
#expect(faviconURL.absoluteString == "https://example.com/apple-icon-precomposed.png")
42+
}
43+
44+
@Test func appleTouchIconWithAbsoluteURL() throws {
45+
// GIVEN
46+
let siteURL = try #require(URL(string: "https://example.com"))
47+
let html = """
48+
<html>
49+
<head>
50+
<link rel="apple-touch-icon" href="https://cdn.example.com/icon.png">
51+
</head>
52+
</html>
53+
"""
54+
let data = Data(html.utf8)
55+
56+
// WHEN
57+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
58+
59+
// THEN
60+
#expect(faviconURL.absoluteString == "https://cdn.example.com/icon.png")
61+
}
62+
63+
@Test func appleTouchIconWithRelativePath() throws {
64+
// GIVEN
65+
let siteURL = try #require(URL(string: "https://example.com"))
66+
let html = """
67+
<html>
68+
<head>
69+
<link rel="apple-touch-icon" href="assets/icon.png">
70+
</head>
71+
</html>
72+
"""
73+
let data = Data(html.utf8)
74+
75+
// WHEN
76+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
77+
78+
// THEN
79+
#expect(faviconURL.absoluteString == "https://example.com/assets/icon.png")
80+
}
81+
82+
@Test func appleTouchIconWithAdditionalAttributes() throws {
83+
// GIVEN
84+
let siteURL = try #require(URL(string: "https://example.com"))
85+
let html = """
86+
<html>
87+
<head>
88+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
89+
</head>
90+
</html>
91+
"""
92+
let data = Data(html.utf8)
93+
94+
// WHEN
95+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
96+
97+
// THEN
98+
#expect(faviconURL.absoluteString == "https://example.com/apple-icon-180x180.png")
99+
}
100+
101+
@Test func appleTouchIconPrecomposedWithSizes() throws {
102+
// GIVEN
103+
let siteURL = try #require(URL(string: "https://example.com"))
104+
let html = """
105+
<html>
106+
<head>
107+
<link rel="apple-touch-icon-precomposed" sizes="152x152" href="/apple-icon-precomposed-152.png">
108+
</head>
109+
</html>
110+
"""
111+
let data = Data(html.utf8)
112+
113+
// WHEN
114+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
115+
116+
// THEN
117+
#expect(faviconURL.absoluteString == "https://example.com/apple-icon-precomposed-152.png")
118+
}
119+
120+
@Test func fallbackToFaviconIcon() throws {
121+
// GIVEN
122+
let siteURL = try #require(URL(string: "https://example.com"))
123+
let html = """
124+
<html>
125+
<head>
126+
<link rel="icon" href="/favicon.ico">
127+
</head>
128+
</html>
129+
"""
130+
let data = Data(html.utf8)
131+
132+
// WHEN
133+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
134+
135+
// THEN
136+
#expect(faviconURL.absoluteString == "https://example.com/favicon.icon")
137+
}
138+
139+
@Test func fallbackWhenNoFaviconFound() throws {
140+
// GIVEN
141+
let siteURL = try #require(URL(string: "https://example.com"))
142+
let html = "<html><head></head></html>"
143+
let data = Data(html.utf8)
144+
145+
// WHEN
146+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
147+
148+
// THEN
149+
#expect(faviconURL.absoluteString == "https://example.com/favicon.icon")
150+
}
151+
152+
@Test func appleTouchIconCaseInsensitive() throws {
153+
// GIVEN
154+
let siteURL = try #require(URL(string: "https://example.com"))
155+
let html = """
156+
<html>
157+
<head>
158+
<link rel="APPLE-TOUCH-ICON" href="/apple-icon.png">
159+
</head>
160+
</html>
161+
"""
162+
let data = Data(html.utf8)
163+
164+
// WHEN
165+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
166+
167+
// THEN
168+
#expect(faviconURL.absoluteString == "https://example.com/apple-icon.png")
169+
}
170+
171+
@Test func multipleAppleTouchIconsUsesFirst() throws {
172+
// GIVEN
173+
let siteURL = try #require(URL(string: "https://example.com"))
174+
let html = """
175+
<html>
176+
<head>
177+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180.png">
178+
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152.png">
179+
<link rel="apple-touch-icon-precomposed" href="/apple-icon-precomposed.png">
180+
</head>
181+
</html>
182+
"""
183+
let data = Data(html.utf8)
184+
185+
// WHEN
186+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
187+
188+
// THEN - Uses the first match
189+
#expect(faviconURL.absoluteString == "https://example.com/apple-icon-180.png")
190+
}
191+
192+
@Test func emptyData() throws {
193+
// GIVEN
194+
let siteURL = try #require(URL(string: "https://example.com"))
195+
let data = Data()
196+
197+
// WHEN
198+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
199+
200+
// THEN - Falls back to standard favicon path
201+
#expect(faviconURL.absoluteString == "https://example.com/favicon.icon")
202+
}
203+
204+
@Test func siteURLWithPath() throws {
205+
// GIVEN
206+
let siteURL = try #require(URL(string: "https://example.com/blog"))
207+
let html = """
208+
<html>
209+
<head>
210+
<link rel="apple-touch-icon" href="/apple-icon.png">
211+
</head>
212+
</html>
213+
"""
214+
let data = Data(html.utf8)
215+
216+
// WHEN
217+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
218+
219+
// THEN
220+
#expect(faviconURL.absoluteString == "https://example.com/apple-icon.png")
221+
}
222+
223+
@Test func siteURLWithPathFallback() throws {
224+
// GIVEN
225+
let siteURL = try #require(URL(string: "https://example.com/blog"))
226+
let html = "<html><head></head></html>"
227+
let data = Data(html.utf8)
228+
229+
// WHEN
230+
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)
231+
232+
// THEN
233+
#expect(faviconURL.absoluteString == "https://example.com/blog/favicon.icon")
234+
}
235+
}

0 commit comments

Comments
 (0)