From e0d44125615bf67231cace2deade944bcb4b3b8f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 27 Nov 2025 12:54:20 -0500 Subject: [PATCH 1/2] Fix regex for apple-touch-icon --- Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift b/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift index 7bbd7df58013..0a533395c982 100644 --- a/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift +++ b/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift @@ -74,7 +74,7 @@ private final class FaviconCache: @unchecked Sendable { } private let regex: NSRegularExpression? = { - let pattern = "]*rel=\"apple-touch-icon(?:-precomposed)\"[^>]*href=\"([^\"]+)\"[^>]*>" + let pattern = "]*rel=\"apple-touch-icon(?:-precomposed)?\"[^>]*href=\"([^\"]+)\"[^>]*>" return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) }() From 396258a7491d367fde1e7c31daa0cac37af429a9 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 27 Nov 2025 13:53:27 -0500 Subject: [PATCH 2/2] Generate a set of unit tests for FaviconServiceTests --- .../Helpers/FaviconService.swift | 29 +-- .../FaviconServiceTests.swift | 235 ++++++++++++++++++ 2 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift diff --git a/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift b/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift index 0a533395c982..678ef0e56a18 100644 --- a/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift +++ b/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift @@ -32,7 +32,7 @@ public actor FaviconService { 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) + return FaviconService.makeFavicon(from: data, siteURL: siteURL) } let subscriptionID = UUID() task.subscriptions.insert(subscriptionID) @@ -55,6 +55,20 @@ public actor FaviconService { task.task.cancel() tasks[key] = nil } + + /// Parses HTML data to extract the apple-touch-icon URL or falls back to /favicon.icon + public static func makeFavicon(from data: Data, siteURL: URL) -> 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") + } } public enum FaviconError: Error, Sendable { @@ -78,19 +92,6 @@ private let regex: NSRegularExpression? = { 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 diff --git a/Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift b/Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift new file mode 100644 index 000000000000..27e91f6dfcb5 --- /dev/null +++ b/Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift @@ -0,0 +1,235 @@ +import UIKit +import Testing +import AsyncImageKit + +@Suite final class FaviconServiceTests { + @Test func appleTouchIcon() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = """ + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/apple-icon.png") + } + + @Test func appleTouchIconPrecomposed() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = """ + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/apple-icon-precomposed.png") + } + + @Test func appleTouchIconWithAbsoluteURL() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = """ + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://cdn.example.com/icon.png") + } + + @Test func appleTouchIconWithRelativePath() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = """ + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/assets/icon.png") + } + + @Test func appleTouchIconWithAdditionalAttributes() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = """ + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/apple-icon-180x180.png") + } + + @Test func appleTouchIconPrecomposedWithSizes() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = """ + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/apple-icon-precomposed-152.png") + } + + @Test func fallbackToFaviconIcon() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = """ + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/favicon.icon") + } + + @Test func fallbackWhenNoFaviconFound() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = "" + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/favicon.icon") + } + + @Test func appleTouchIconCaseInsensitive() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = """ + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/apple-icon.png") + } + + @Test func multipleAppleTouchIconsUsesFirst() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let html = """ + + + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN - Uses the first match + #expect(faviconURL.absoluteString == "https://example.com/apple-icon-180.png") + } + + @Test func emptyData() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com")) + let data = Data() + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN - Falls back to standard favicon path + #expect(faviconURL.absoluteString == "https://example.com/favicon.icon") + } + + @Test func siteURLWithPath() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com/blog")) + let html = """ + + + + + + """ + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/apple-icon.png") + } + + @Test func siteURLWithPathFallback() throws { + // GIVEN + let siteURL = try #require(URL(string: "https://example.com/blog")) + let html = "" + let data = Data(html.utf8) + + // WHEN + let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL) + + // THEN + #expect(faviconURL.absoluteString == "https://example.com/blog/favicon.icon") + } +}