Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't spot any real issue here, but I think the casting between Range and NSRange, String and NSString, and potentially unsafe subscript, could be avoided by using Swift.Regex.

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 {
Expand All @@ -74,23 +88,10 @@ private final class FaviconCache: @unchecked Sendable {
}

private let regex: NSRegularExpression? = {
let pattern = "<link[^>]*rel=\"apple-touch-icon(?:-precomposed)\"[^>]*href=\"([^\"]+)\"[^>]*>"
let pattern = "<link[^>]*rel=\"apple-touch-icon(?:-precomposed)?\"[^>]*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
Expand Down
235 changes: 235 additions & 0 deletions Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift
Original file line number Diff line number Diff line change
@@ -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"))

Check warning on line 8 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfAz&open=AZrGrZINM7i0c6huLfAz&pullRequest=25025

Check failure on line 8 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 11 times.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBP&open=AZrGrZINM7i0c6huLfBP&pullRequest=25025
let html = """
<html>
<head>
<link rel="apple-touch-icon" href="/apple-icon.png">
</head>
</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")

Check warning on line 22 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA0&open=AZrGrZINM7i0c6huLfA0&pullRequest=25025

Check failure on line 22 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 3 times.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBO&open=AZrGrZINM7i0c6huLfBO&pullRequest=25025
}

@Test func appleTouchIconPrecomposed() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 27 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA1&open=AZrGrZINM7i0c6huLfA1&pullRequest=25025
let html = """
<html>
<head>
<link rel="apple-touch-icon-precomposed" href="/apple-icon-precomposed.png">
</head>
</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")

Check warning on line 41 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA2&open=AZrGrZINM7i0c6huLfA2&pullRequest=25025
}

@Test func appleTouchIconWithAbsoluteURL() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 46 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA3&open=AZrGrZINM7i0c6huLfA3&pullRequest=25025
let html = """
<html>
<head>
<link rel="apple-touch-icon" href="https://cdn.example.com/icon.png">
</head>
</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")

Check warning on line 60 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA4&open=AZrGrZINM7i0c6huLfA4&pullRequest=25025
}

@Test func appleTouchIconWithRelativePath() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 65 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA5&open=AZrGrZINM7i0c6huLfA5&pullRequest=25025
let html = """
<html>
<head>
<link rel="apple-touch-icon" href="assets/icon.png">
</head>
</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")

Check warning on line 79 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA6&open=AZrGrZINM7i0c6huLfA6&pullRequest=25025
}

@Test func appleTouchIconWithAdditionalAttributes() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 84 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA7&open=AZrGrZINM7i0c6huLfA7&pullRequest=25025
let html = """
<html>
<head>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
</head>
</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")

Check warning on line 98 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA8&open=AZrGrZINM7i0c6huLfA8&pullRequest=25025
}

@Test func appleTouchIconPrecomposedWithSizes() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 103 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA9&open=AZrGrZINM7i0c6huLfA9&pullRequest=25025
let html = """
<html>
<head>
<link rel="apple-touch-icon-precomposed" sizes="152x152" href="/apple-icon-precomposed-152.png">
</head>
</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")

Check warning on line 117 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA-&open=AZrGrZINM7i0c6huLfA-&pullRequest=25025
}

@Test func fallbackToFaviconIcon() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 122 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfA_&open=AZrGrZINM7i0c6huLfA_&pullRequest=25025
let html = """
<html>
<head>
<link rel="icon" href="/favicon.ico">
</head>
</html>
"""
let data = Data(html.utf8)

// WHEN
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)

// THEN
#expect(faviconURL.absoluteString == "https://example.com/favicon.icon")

Check warning on line 136 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBA&open=AZrGrZINM7i0c6huLfBA&pullRequest=25025

Check failure on line 136 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 3 times.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBN&open=AZrGrZINM7i0c6huLfBN&pullRequest=25025
}

@Test func fallbackWhenNoFaviconFound() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 141 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBB&open=AZrGrZINM7i0c6huLfBB&pullRequest=25025
let html = "<html><head></head></html>"
let data = Data(html.utf8)

// WHEN
let faviconURL = FaviconService.makeFavicon(from: data, siteURL: siteURL)

// THEN
#expect(faviconURL.absoluteString == "https://example.com/favicon.icon")

Check warning on line 149 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBC&open=AZrGrZINM7i0c6huLfBC&pullRequest=25025
}

@Test func appleTouchIconCaseInsensitive() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 154 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBD&open=AZrGrZINM7i0c6huLfBD&pullRequest=25025
let html = """
<html>
<head>
<link rel="APPLE-TOUCH-ICON" href="/apple-icon.png">
</head>
</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")

Check warning on line 168 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBE&open=AZrGrZINM7i0c6huLfBE&pullRequest=25025
}

@Test func multipleAppleTouchIconsUsesFirst() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 173 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBF&open=AZrGrZINM7i0c6huLfBF&pullRequest=25025
let html = """
<html>
<head>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180.png">
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152.png">
<link rel="apple-touch-icon-precomposed" href="/apple-icon-precomposed.png">
</head>
</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")

Check warning on line 189 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBG&open=AZrGrZINM7i0c6huLfBG&pullRequest=25025
}

@Test func emptyData() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com"))

Check warning on line 194 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBH&open=AZrGrZINM7i0c6huLfBH&pullRequest=25025
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")

Check warning on line 201 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBI&open=AZrGrZINM7i0c6huLfBI&pullRequest=25025
}

@Test func siteURLWithPath() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com/blog"))

Check warning on line 206 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBJ&open=AZrGrZINM7i0c6huLfBJ&pullRequest=25025
let html = """
<html>
<head>
<link rel="apple-touch-icon" href="/apple-icon.png">
</head>
</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")

Check warning on line 220 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBK&open=AZrGrZINM7i0c6huLfBK&pullRequest=25025
}

@Test func siteURLWithPathFallback() throws {
// GIVEN
let siteURL = try #require(URL(string: "https://example.com/blog"))

Check warning on line 225 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBL&open=AZrGrZINM7i0c6huLfBL&pullRequest=25025
let html = "<html><head></head></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")

Check warning on line 233 in Modules/Tests/AsyncImageKitTests/FaviconServiceTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrGrZINM7i0c6huLfBM&open=AZrGrZINM7i0c6huLfBM&pullRequest=25025
}
}