mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(ios): og:image thumbnails on link preview cards (#101387)
This commit is contained in:
parent
bc5a82b26d
commit
508296f1ed
2 changed files with 551 additions and 64 deletions
|
|
@ -1,15 +1,20 @@
|
|||
import Darwin
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Markdown
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
let chatLinkPreviewTitleMaxCharacters = 120
|
||||
let chatLinkPreviewDescriptionMaxCharacters = 200
|
||||
let chatLinkPreviewBodyMaxBytes = 512 * 1024
|
||||
let chatLinkPreviewImageBodyMaxBytes = 1024 * 1024
|
||||
let chatLinkPreviewImageMaxPixelSize = 600
|
||||
let chatLinkPreviewImageMaxSourcePixels = 64 * 1024 * 1024
|
||||
private let chatLinkPreviewMaxRedirects = 3
|
||||
private let chatLinkPreviewTimeout: TimeInterval = 6
|
||||
private let chatLinkPreviewCacheEntries = 64
|
||||
private let chatLinkPreviewAccept = "text/html"
|
||||
private let chatLinkPreviewImageCacheEntries = 32
|
||||
|
||||
struct ChatLinkPreviewMetadata: Equatable {
|
||||
let url: URL
|
||||
|
|
@ -23,6 +28,23 @@ enum ChatLinkPreviewResult: Equatable {
|
|||
case failed
|
||||
}
|
||||
|
||||
struct ChatLinkPreviewThumbnail: @unchecked Sendable {
|
||||
let image: CGImage
|
||||
|
||||
var pixelWidth: Int {
|
||||
self.image.width
|
||||
}
|
||||
|
||||
var pixelHeight: Int {
|
||||
self.image.height
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatLinkPreviewImageResult: @unchecked Sendable {
|
||||
case loaded(ChatLinkPreviewThumbnail)
|
||||
case failed
|
||||
}
|
||||
|
||||
/// Returns the first HTTP(S) link outside inline and block code.
|
||||
func chatFirstPreviewURL(in markdown: String) -> URL? {
|
||||
chatFirstPreviewURL(in: Document(parsing: markdown))
|
||||
|
|
@ -68,7 +90,9 @@ private func chatFirstBareWebURL(in text: String) -> URL? {
|
|||
extension String {
|
||||
fileprivate func count(of character: Character) -> Int {
|
||||
self.reduce(into: 0) { count, current in
|
||||
if current == character { count += 1 }
|
||||
if current == character {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -342,13 +366,48 @@ func chatLinkPreviewRedirectURL(
|
|||
}
|
||||
|
||||
struct ChatLinkPreviewBodyAccumulator {
|
||||
private let maxBytes: Int
|
||||
private(set) var data = Data()
|
||||
|
||||
init(maxBytes: Int = chatLinkPreviewBodyMaxBytes) {
|
||||
self.maxBytes = maxBytes
|
||||
}
|
||||
|
||||
mutating func append(_ chunk: Data) -> Bool {
|
||||
let remaining = chatLinkPreviewBodyMaxBytes - self.data.count
|
||||
let remaining = self.maxBytes - self.data.count
|
||||
guard remaining > 0 else { return true }
|
||||
self.data.append(chunk.prefix(remaining))
|
||||
return chunk.count >= remaining
|
||||
return chunk.count > remaining
|
||||
}
|
||||
}
|
||||
|
||||
private enum ChatLinkPreviewFetchMode {
|
||||
case metadata
|
||||
case image
|
||||
|
||||
var accept: String {
|
||||
switch self {
|
||||
case .metadata: "text/html"
|
||||
case .image: "image/*"
|
||||
}
|
||||
}
|
||||
|
||||
var allowedMIMETypes: Set<String> {
|
||||
switch self {
|
||||
case .metadata: ["text/html"]
|
||||
case .image: ["image/jpeg", "image/png", "image/webp", "image/gif"]
|
||||
}
|
||||
}
|
||||
|
||||
var maxBodyBytes: Int {
|
||||
switch self {
|
||||
case .metadata: chatLinkPreviewBodyMaxBytes
|
||||
case .image: chatLinkPreviewImageBodyMaxBytes
|
||||
}
|
||||
}
|
||||
|
||||
var rejectsCappedBody: Bool {
|
||||
self == .image
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -374,16 +433,45 @@ final class ChatLinkPreviewFetcher: @unchecked Sendable {
|
|||
}
|
||||
|
||||
func fetch(_ originalURL: URL) async -> ChatLinkPreviewResult {
|
||||
guard let response = await self.fetchResponse(originalURL, mode: .metadata),
|
||||
let html = String(bytes: response.data, encoding: .utf8)
|
||||
else { return .failed }
|
||||
return switch parseChatOpenGraph(html: html, baseURL: response.url) {
|
||||
case let .loaded(metadata):
|
||||
.loaded(ChatLinkPreviewMetadata(
|
||||
url: originalURL,
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
imageURL: metadata.imageURL))
|
||||
case .failed:
|
||||
.failed
|
||||
}
|
||||
}
|
||||
|
||||
func fetchImage(_ originalURL: URL) async -> ChatLinkPreviewImageResult {
|
||||
guard let response = await self.fetchResponse(originalURL, mode: .image),
|
||||
let thumbnail = chatDecodeLinkPreviewThumbnail(
|
||||
response.data,
|
||||
mimeType: response.mimeType)
|
||||
else { return .failed }
|
||||
return .loaded(thumbnail)
|
||||
}
|
||||
|
||||
private func fetchResponse(
|
||||
_ originalURL: URL,
|
||||
mode: ChatLinkPreviewFetchMode) async -> ChatLinkPreviewResponse?
|
||||
{
|
||||
guard chatSafeWebURL(originalURL.absoluteString) != nil, self.hostPolicy(originalURL) else {
|
||||
return .failed
|
||||
return nil
|
||||
}
|
||||
let delegate = ChatLinkPreviewSessionDelegate(
|
||||
mode: mode,
|
||||
hostPolicy: self.hostPolicy,
|
||||
connectionPolicy: self.connectionPolicy)
|
||||
let session = URLSession(configuration: self.configuration, delegate: delegate, delegateQueue: nil)
|
||||
var request = URLRequest(url: originalURL, timeoutInterval: self.timeout)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(chatLinkPreviewAccept, forHTTPHeaderField: "Accept")
|
||||
request.setValue(mode.accept, forHTTPHeaderField: "Accept")
|
||||
request.httpShouldHandleCookies = false
|
||||
let task = session.dataTask(with: request)
|
||||
let deadline = Task {
|
||||
|
|
@ -402,20 +490,7 @@ final class ChatLinkPreviewFetcher: @unchecked Sendable {
|
|||
deadline.cancel()
|
||||
session.finishTasksAndInvalidate()
|
||||
|
||||
guard
|
||||
let response,
|
||||
let html = String(bytes: response.data, encoding: .utf8)
|
||||
else { return .failed }
|
||||
return switch parseChatOpenGraph(html: html, baseURL: response.url) {
|
||||
case let .loaded(metadata):
|
||||
.loaded(ChatLinkPreviewMetadata(
|
||||
url: originalURL,
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
imageURL: metadata.imageURL))
|
||||
case .failed:
|
||||
.failed
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private static func publicConnectionsOnly(_ addresses: [String?]) -> Bool {
|
||||
|
|
@ -442,16 +517,19 @@ extension URLSessionConfiguration {
|
|||
|
||||
private struct ChatLinkPreviewResponse {
|
||||
let url: URL
|
||||
let mimeType: String
|
||||
let data: Data
|
||||
}
|
||||
|
||||
private final class ChatLinkPreviewSessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let mode: ChatLinkPreviewFetchMode
|
||||
private let hostPolicy: ChatLinkPreviewFetcher.HostPolicy
|
||||
private let connectionPolicy: ChatLinkPreviewFetcher.ConnectionPolicy
|
||||
private var continuation: CheckedContinuation<ChatLinkPreviewResponse?, Never>?
|
||||
private var responseURL: URL?
|
||||
private var body = ChatLinkPreviewBodyAccumulator()
|
||||
private var body: ChatLinkPreviewBodyAccumulator
|
||||
private var responseMIMEType: String?
|
||||
private var redirectCount = 0
|
||||
private var remoteAddresses: [String?] = []
|
||||
private var bodyCapped = false
|
||||
|
|
@ -459,11 +537,14 @@ private final class ChatLinkPreviewSessionDelegate: NSObject, URLSessionDataDele
|
|||
private var completed = false
|
||||
|
||||
init(
|
||||
mode: ChatLinkPreviewFetchMode,
|
||||
hostPolicy: @escaping ChatLinkPreviewFetcher.HostPolicy,
|
||||
connectionPolicy: @escaping ChatLinkPreviewFetcher.ConnectionPolicy)
|
||||
{
|
||||
self.mode = mode
|
||||
self.hostPolicy = hostPolicy
|
||||
self.connectionPolicy = connectionPolicy
|
||||
self.body = ChatLinkPreviewBodyAccumulator(maxBytes: mode.maxBodyBytes)
|
||||
}
|
||||
|
||||
func start(_ continuation: CheckedContinuation<ChatLinkPreviewResponse?, Never>) {
|
||||
|
|
@ -497,7 +578,9 @@ private final class ChatLinkPreviewSessionDelegate: NSObject, URLSessionDataDele
|
|||
request: request,
|
||||
redirectCount: self.redirectCount,
|
||||
hostPolicy: self.hostPolicy)
|
||||
if url != nil { self.redirectCount += 1 }
|
||||
if url != nil {
|
||||
self.redirectCount += 1
|
||||
}
|
||||
return url
|
||||
}
|
||||
completionHandler(nextURL == nil ? nil : request)
|
||||
|
|
@ -511,24 +594,32 @@ private final class ChatLinkPreviewSessionDelegate: NSObject, URLSessionDataDele
|
|||
{
|
||||
guard let http = response as? HTTPURLResponse,
|
||||
(200..<300).contains(http.statusCode),
|
||||
http.mimeType?.lowercased() == "text/html",
|
||||
let mimeType = http.mimeType?.lowercased(),
|
||||
self.mode.allowedMIMETypes.contains(mimeType),
|
||||
let url = http.url
|
||||
else {
|
||||
self.lock.withLock { self.failed = true }
|
||||
completionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
self.lock.withLock { self.responseURL = url }
|
||||
self.lock.withLock {
|
||||
self.responseURL = url
|
||||
self.responseMIMEType = mimeType
|
||||
}
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
let reachedCap = self.lock.withLock {
|
||||
let reachedCap = self.body.append(data)
|
||||
if reachedCap { self.bodyCapped = true }
|
||||
if reachedCap {
|
||||
self.bodyCapped = true
|
||||
}
|
||||
return reachedCap
|
||||
}
|
||||
if reachedCap { dataTask.cancel() }
|
||||
if reachedCap {
|
||||
dataTask.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_: URLSession, task _: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
|
||||
|
|
@ -545,9 +636,14 @@ private final class ChatLinkPreviewSessionDelegate: NSObject, URLSessionDataDele
|
|||
guard !self.failed,
|
||||
error == nil || self.bodyCapped,
|
||||
let responseURL,
|
||||
let responseMIMEType,
|
||||
!(self.mode.rejectsCappedBody && self.bodyCapped),
|
||||
self.connectionPolicy(self.remoteAddresses)
|
||||
else { return nil }
|
||||
return ChatLinkPreviewResponse(url: responseURL, data: self.body.data)
|
||||
return ChatLinkPreviewResponse(
|
||||
url: responseURL,
|
||||
mimeType: responseMIMEType,
|
||||
data: self.body.data)
|
||||
}
|
||||
self.complete(result)
|
||||
}
|
||||
|
|
@ -563,6 +659,34 @@ private final class ChatLinkPreviewSessionDelegate: NSObject, URLSessionDataDele
|
|||
}
|
||||
}
|
||||
|
||||
func chatDecodeLinkPreviewThumbnail(
|
||||
_ data: Data,
|
||||
mimeType: String,
|
||||
maxSourcePixels: Int = chatLinkPreviewImageMaxSourcePixels) -> ChatLinkPreviewThumbnail?
|
||||
{
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
CGImageSourceGetCount(source) > 0,
|
||||
mimeType != "image/gif" || CGImageSourceGetCount(source) == 1,
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue,
|
||||
let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue,
|
||||
width > 0,
|
||||
height > 0,
|
||||
height <= maxSourcePixels,
|
||||
width <= maxSourcePixels / height
|
||||
else { return nil }
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: chatLinkPreviewImageMaxPixelSize,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
]
|
||||
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
||||
return nil
|
||||
}
|
||||
return ChatLinkPreviewThumbnail(image: image)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ChatLinkPreviewStore {
|
||||
typealias Fetch = @Sendable (URL) async -> ChatLinkPreviewResult
|
||||
|
|
@ -583,6 +707,43 @@ final class ChatLinkPreviewStore {
|
|||
return cached
|
||||
}
|
||||
let result = await self.fetch(url)
|
||||
guard !Task.isCancelled else { return result }
|
||||
self.cache[url] = result
|
||||
self.touch(url)
|
||||
while self.recency.count > self.maxEntries, let evicted = self.recency.first {
|
||||
self.recency.removeFirst()
|
||||
self.cache.removeValue(forKey: evicted)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func touch(_ url: URL) {
|
||||
self.recency.removeAll { $0 == url }
|
||||
self.recency.append(url)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ChatLinkPreviewImageStore {
|
||||
typealias Fetch = @Sendable (URL) async -> ChatLinkPreviewImageResult
|
||||
|
||||
private let fetch: Fetch
|
||||
private let maxEntries: Int
|
||||
private var cache: [URL: ChatLinkPreviewImageResult] = [:]
|
||||
private var recency: [URL] = []
|
||||
|
||||
init(maxEntries: Int = chatLinkPreviewImageCacheEntries, fetch: @escaping Fetch) {
|
||||
self.maxEntries = maxEntries
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
func get(_ url: URL) async -> ChatLinkPreviewImageResult {
|
||||
if let cached = self.cache[url] {
|
||||
self.touch(url)
|
||||
return cached
|
||||
}
|
||||
let result = await self.fetch(url)
|
||||
guard !Task.isCancelled else { return result }
|
||||
self.cache[url] = result
|
||||
self.touch(url)
|
||||
while self.recency.count > self.maxEntries, let evicted = self.recency.first {
|
||||
|
|
@ -601,19 +762,68 @@ final class ChatLinkPreviewStore {
|
|||
@MainActor
|
||||
private let chatLinkPreviewStore = ChatLinkPreviewStore(fetch: ChatLinkPreviewFetcher().fetch)
|
||||
|
||||
@MainActor
|
||||
private let chatLinkPreviewImageStore = ChatLinkPreviewImageStore(fetch: ChatLinkPreviewFetcher().fetchImage)
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ChatLinkPreviewModel {
|
||||
typealias MetadataFetch = @Sendable (URL) async -> ChatLinkPreviewResult
|
||||
typealias ImageFetch = @Sendable (URL) async -> ChatLinkPreviewImageResult
|
||||
|
||||
var expanded = false
|
||||
private(set) var result: ChatLinkPreviewResult?
|
||||
private(set) var imageResult: ChatLinkPreviewImageResult?
|
||||
private let metadataFetch: MetadataFetch
|
||||
private let imageFetch: ImageFetch
|
||||
|
||||
init(metadataFetch: @escaping MetadataFetch, imageFetch: @escaping ImageFetch) {
|
||||
self.metadataFetch = metadataFetch
|
||||
self.imageFetch = imageFetch
|
||||
}
|
||||
|
||||
var imageURL: URL? {
|
||||
guard case let .loaded(metadata) = self.result else { return nil }
|
||||
return metadata.imageURL
|
||||
}
|
||||
|
||||
func loadMetadata(_ url: URL) async {
|
||||
guard self.expanded, self.result == nil else { return }
|
||||
self.result = await self.metadataFetch(url)
|
||||
}
|
||||
|
||||
func loadImage() async {
|
||||
guard self.expanded,
|
||||
self.imageResult == nil,
|
||||
let imageURL = self.imageURL
|
||||
else { return }
|
||||
let result = await self.imageFetch(imageURL)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.imageResult = result
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatLinkPreview: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
let url: URL
|
||||
@State private var expanded = false
|
||||
@State private var result: ChatLinkPreviewResult?
|
||||
@State private var model: ChatLinkPreviewModel
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
self._model = State(initialValue: ChatLinkPreviewModel(
|
||||
metadataFetch: chatLinkPreviewStore.get,
|
||||
imageFetch: chatLinkPreviewImageStore.get))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if self.expanded {
|
||||
if self.model.expanded {
|
||||
self.expandedCard
|
||||
.task(id: self.url) {
|
||||
guard self.result == nil else { return }
|
||||
self.result = await chatLinkPreviewStore.get(self.url)
|
||||
await self.model.loadMetadata(self.url)
|
||||
}
|
||||
.task(id: self.model.imageURL) {
|
||||
await self.model.loadImage()
|
||||
}
|
||||
} else {
|
||||
self.collapsedChip
|
||||
|
|
@ -622,7 +832,7 @@ struct ChatLinkPreview: View {
|
|||
|
||||
private var collapsedChip: some View {
|
||||
Button {
|
||||
self.expanded = true
|
||||
self.model.expanded = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text("Preview · \(self.domain)")
|
||||
|
|
@ -651,11 +861,20 @@ struct ChatLinkPreview: View {
|
|||
self.openURL(self.url)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
if case let .loaded(thumbnail) = self.model.imageResult {
|
||||
Image(decorative: thumbnail.image, scale: 1)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 120)
|
||||
.clipped()
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
Text(self.domain)
|
||||
.font(OpenClawChatTypography.caption2)
|
||||
.foregroundStyle(OpenClawChatTheme.assistantText.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
switch self.result {
|
||||
switch self.model.result {
|
||||
case nil:
|
||||
Text("Loading preview…")
|
||||
.font(OpenClawChatTypography.caption)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
struct ChatLinkPreviewExtractionTests {
|
||||
|
|
@ -145,6 +147,21 @@ struct ChatLinkPreviewFetcherDecisionTests {
|
|||
redirectCount: 0) == nil)
|
||||
}
|
||||
|
||||
@Test func `image redirects use the same hop and host rules`() throws {
|
||||
let originalURL = try #require(URL(string: "https://images.example/start"))
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: originalURL,
|
||||
statusCode: 302,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Location": "http://127.0.0.1/private.png"]))
|
||||
let privateRequest = try URLRequest(url: #require(URL(string: "http://127.0.0.1/private.png")))
|
||||
|
||||
#expect(chatLinkPreviewRedirectURL(
|
||||
response: response,
|
||||
request: privateRequest,
|
||||
redirectCount: 0) == nil)
|
||||
}
|
||||
|
||||
@Test func `body accumulator keeps only the prefix`() {
|
||||
var accumulator = ChatLinkPreviewBodyAccumulator()
|
||||
let firstReachedCap = accumulator.append(Data(repeating: 1, count: 64))
|
||||
|
|
@ -157,40 +174,217 @@ struct ChatLinkPreviewFetcherDecisionTests {
|
|||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ChatLinkPreviewFetcherTests {
|
||||
@Test func `fetches HTML only after an explicit call`() async throws {
|
||||
ChatLinkPreviewStubURLProtocol.set(.response(
|
||||
headers: ["Content-Type": "text/html; charset=utf-8"],
|
||||
data: Data("<meta property='og:title' content='Fetched title'>".utf8)))
|
||||
let fetcher = self.fetcher(timeout: 1)
|
||||
let url = try #require(URL(string: "https://preview.test/story"))
|
||||
struct ChatLinkPreviewNetworkTests {
|
||||
struct ChatLinkPreviewFetcherTests {
|
||||
@Test func `fetches HTML only after an explicit call`() async throws {
|
||||
ChatLinkPreviewStubURLProtocol.set(.response(
|
||||
headers: ["Content-Type": "text/html; charset=utf-8"],
|
||||
data: Data("<meta property='og:title' content='Fetched title'>".utf8)))
|
||||
let fetcher = self.fetcher(timeout: 1)
|
||||
let url = try #require(URL(string: "https://preview.test/story"))
|
||||
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 0)
|
||||
let metadata = try #require(await fetcher.fetch(url).metadata)
|
||||
#expect(metadata.title == "Fetched title")
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 1)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.lastAcceptHeader == "text/html")
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 0)
|
||||
let metadata = try #require(await fetcher.fetch(url).metadata)
|
||||
#expect(metadata.title == "Fetched title")
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 1)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.lastAcceptHeader == "text/html")
|
||||
}
|
||||
|
||||
@Test func `total deadline can fire before the session starts`() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [ChatLinkPreviewHangingURLProtocol.self]
|
||||
let fetcher = ChatLinkPreviewFetcher(
|
||||
configuration: configuration,
|
||||
timeout: 0,
|
||||
hostPolicy: { _ in true },
|
||||
connectionPolicy: { _ in true })
|
||||
let url = try #require(URL(string: "https://preview.test/slow"))
|
||||
let clock = ContinuousClock()
|
||||
let start = clock.now
|
||||
|
||||
#expect(await fetcher.fetch(url) == .failed)
|
||||
#expect(start.duration(to: clock.now) < .seconds(1))
|
||||
}
|
||||
|
||||
@Test func `image fetch accepts only images and enforces its body cap`() async throws {
|
||||
let fetcher = self.fetcher(timeout: 1)
|
||||
let url = try #require(URL(string: "https://preview.test/image"))
|
||||
|
||||
ChatLinkPreviewStubURLProtocol.set(.response(
|
||||
headers: ["Content-Type": "text/plain"],
|
||||
data: Data("not an image".utf8)))
|
||||
#expect(await fetcher.fetchImage(url).thumbnail == nil)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.lastAcceptHeader == "image/*")
|
||||
|
||||
ChatLinkPreviewStubURLProtocol.set(.response(
|
||||
headers: ["Content-Type": "image/png"],
|
||||
data: Data(repeating: 0, count: chatLinkPreviewImageBodyMaxBytes + 1)))
|
||||
#expect(await fetcher.fetchImage(url).thumbnail == nil)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 1)
|
||||
}
|
||||
|
||||
@Test func `image host policy rejects private literal without a request`() async throws {
|
||||
try ChatLinkPreviewStubURLProtocol.set(.response(
|
||||
headers: ["Content-Type": "image/png"],
|
||||
data: makeChatLinkPreviewPNG(width: 4, height: 4)))
|
||||
let fetcher = self.fetcher(timeout: 1, hostPolicy: chatLinkPreviewAllowsHost)
|
||||
let url = try #require(URL(string: "http://127.0.0.1/private.png"))
|
||||
|
||||
#expect(await fetcher.fetchImage(url).thumbnail == nil)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 0)
|
||||
}
|
||||
|
||||
private func fetcher(
|
||||
timeout: TimeInterval,
|
||||
hostPolicy: @escaping ChatLinkPreviewFetcher.HostPolicy = { _ in true }) -> ChatLinkPreviewFetcher
|
||||
{
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [ChatLinkPreviewStubURLProtocol.self]
|
||||
return ChatLinkPreviewFetcher(
|
||||
configuration: configuration,
|
||||
timeout: timeout,
|
||||
hostPolicy: hostPolicy,
|
||||
connectionPolicy: { _ in true })
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `total deadline can fire before the session starts`() async throws {
|
||||
ChatLinkPreviewStubURLProtocol.set(.hanging)
|
||||
let fetcher = self.fetcher(timeout: 0)
|
||||
let url = try #require(URL(string: "https://preview.test/slow"))
|
||||
let clock = ContinuousClock()
|
||||
let start = clock.now
|
||||
struct ChatLinkPreviewImageDecodeTests {
|
||||
@Test func `oversized source decodes to bounded thumbnail`() throws {
|
||||
let data = try makeChatLinkPreviewPNG(width: 1200, height: 800)
|
||||
let thumbnail = try #require(chatDecodeLinkPreviewThumbnail(data, mimeType: "image/png"))
|
||||
|
||||
#expect(await fetcher.fetch(url) == .failed)
|
||||
#expect(start.duration(to: clock.now) < .seconds(1))
|
||||
#expect(max(thumbnail.pixelWidth, thumbnail.pixelHeight) == chatLinkPreviewImageMaxPixelSize)
|
||||
#expect(min(thumbnail.pixelWidth, thumbnail.pixelHeight) <= chatLinkPreviewImageMaxPixelSize)
|
||||
}
|
||||
|
||||
@Test func `source pixel limit rejects oversized dimensions before decode`() throws {
|
||||
let data = try makeChatLinkPreviewPNG(width: 1200, height: 800)
|
||||
|
||||
#expect(chatDecodeLinkPreviewThumbnail(
|
||||
data,
|
||||
mimeType: "image/png",
|
||||
maxSourcePixels: 900_000) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetcher(timeout: TimeInterval) -> ChatLinkPreviewFetcher {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [ChatLinkPreviewStubURLProtocol.self]
|
||||
return ChatLinkPreviewFetcher(
|
||||
configuration: configuration,
|
||||
timeout: timeout,
|
||||
hostPolicy: { _ in true },
|
||||
connectionPolicy: { _ in true })
|
||||
@MainActor
|
||||
struct ChatLinkPreviewImageFlowTests {
|
||||
@Test func `image request waits for expansion and loaded metadata`() async throws {
|
||||
let pageURL = try #require(URL(string: "https://preview.test/story"))
|
||||
let imageURL = try #require(URL(string: "https://cdn.test/cover.png"))
|
||||
let imageData = try makeChatLinkPreviewPNG(width: 12, height: 8)
|
||||
ChatLinkPreviewStubURLProtocol.set { request in
|
||||
if request.url == pageURL {
|
||||
return .response(
|
||||
headers: ["Content-Type": "text/html"],
|
||||
data: Data(
|
||||
"<meta property='og:title' content='Story'><meta property='og:image' content='\(imageURL.absoluteString)'>"
|
||||
.utf8))
|
||||
}
|
||||
return .response(headers: ["Content-Type": "image/png"], data: imageData)
|
||||
}
|
||||
let fetcher = self.fetcher()
|
||||
let model = ChatLinkPreviewModel(
|
||||
metadataFetch: fetcher.fetch,
|
||||
imageFetch: fetcher.fetchImage)
|
||||
|
||||
await model.loadMetadata(pageURL)
|
||||
await model.loadImage()
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 0)
|
||||
|
||||
model.expanded = true
|
||||
await model.loadMetadata(pageURL)
|
||||
#expect(model.result?.metadata?.imageURL == imageURL)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 1)
|
||||
|
||||
await model.loadImage()
|
||||
#expect(model.imageResult?.thumbnail != nil)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 2)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestURLs == [pageURL, imageURL])
|
||||
}
|
||||
|
||||
@Test func `corrupt image is negative cached without refetch`() async throws {
|
||||
ChatLinkPreviewStubURLProtocol.set(.response(
|
||||
headers: ["Content-Type": "image/png"],
|
||||
data: Data("corrupt".utf8)))
|
||||
let fetcher = self.fetcher()
|
||||
let store = ChatLinkPreviewImageStore(fetch: fetcher.fetchImage)
|
||||
let url = try #require(URL(string: "https://preview.test/corrupt.png"))
|
||||
|
||||
#expect(await store.get(url).thumbnail == nil)
|
||||
#expect(await store.get(url).thumbnail == nil)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 1)
|
||||
}
|
||||
|
||||
@Test func `decoded image cache hit avoids second request`() async throws {
|
||||
try ChatLinkPreviewStubURLProtocol.set(.response(
|
||||
headers: ["Content-Type": "image/png"],
|
||||
data: makeChatLinkPreviewPNG(width: 8, height: 4)))
|
||||
let fetcher = self.fetcher()
|
||||
let store = ChatLinkPreviewImageStore(fetch: fetcher.fetchImage)
|
||||
let url = try #require(URL(string: "https://preview.test/cached.png"))
|
||||
|
||||
#expect(await store.get(url).thumbnail != nil)
|
||||
#expect(await store.get(url).thumbnail != nil)
|
||||
#expect(ChatLinkPreviewStubURLProtocol.requestCount == 1)
|
||||
}
|
||||
|
||||
@Test func `cancelled image load is neither cached nor published`() async throws {
|
||||
let pageURL = try #require(URL(string: "https://preview.test/story"))
|
||||
let imageURL = try #require(URL(string: "https://preview.test/cancelled.png"))
|
||||
let storeAttempts = ChatLinkPreviewFetchCounter()
|
||||
let store = ChatLinkPreviewImageStore { _ in
|
||||
let attempt = await storeAttempts.incrementAndGet()
|
||||
if attempt == 1 {
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
}
|
||||
return .failed
|
||||
}
|
||||
|
||||
let storeTask = Task { await store.get(imageURL) }
|
||||
while await storeAttempts.value == 0 {
|
||||
await Task.yield()
|
||||
}
|
||||
storeTask.cancel()
|
||||
_ = await storeTask.value
|
||||
_ = await store.get(imageURL)
|
||||
#expect(await storeAttempts.value == 2)
|
||||
|
||||
let modelAttempts = ChatLinkPreviewFetchCounter()
|
||||
let model = ChatLinkPreviewModel(
|
||||
metadataFetch: { _ in
|
||||
.loaded(ChatLinkPreviewMetadata(
|
||||
url: pageURL,
|
||||
title: "Story",
|
||||
description: nil,
|
||||
imageURL: imageURL))
|
||||
},
|
||||
imageFetch: { _ in
|
||||
await modelAttempts.increment()
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
return .failed
|
||||
})
|
||||
model.expanded = true
|
||||
await model.loadMetadata(pageURL)
|
||||
|
||||
let modelTask = Task { await model.loadImage() }
|
||||
while await modelAttempts.value == 0 {
|
||||
await Task.yield()
|
||||
}
|
||||
modelTask.cancel()
|
||||
await modelTask.value
|
||||
#expect(model.imageResult == nil)
|
||||
}
|
||||
|
||||
private func fetcher() -> ChatLinkPreviewFetcher {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [ChatLinkPreviewStubURLProtocol.self]
|
||||
return ChatLinkPreviewFetcher(
|
||||
configuration: configuration,
|
||||
timeout: 1,
|
||||
hostPolicy: { _ in true },
|
||||
connectionPolicy: { _ in true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +410,11 @@ private actor ChatLinkPreviewFetchCounter {
|
|||
func increment() {
|
||||
self.value += 1
|
||||
}
|
||||
|
||||
func incrementAndGet() -> Int {
|
||||
self.value += 1
|
||||
return self.value
|
||||
}
|
||||
}
|
||||
|
||||
private final class ChatLinkPreviewStubURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
|
|
@ -226,8 +425,10 @@ private final class ChatLinkPreviewStubURLProtocol: URLProtocol, @unchecked Send
|
|||
|
||||
private static let lock = NSLock()
|
||||
private nonisolated(unsafe) static var stub: Stub = .hanging
|
||||
private nonisolated(unsafe) static var handler: (@Sendable (URLRequest) -> Stub)?
|
||||
private nonisolated(unsafe) static var requests = 0
|
||||
private nonisolated(unsafe) static var acceptHeader: String?
|
||||
private nonisolated(unsafe) static var urls: [URL] = []
|
||||
|
||||
static var requestCount: Int {
|
||||
self.lock.withLock { self.requests }
|
||||
|
|
@ -237,11 +438,26 @@ private final class ChatLinkPreviewStubURLProtocol: URLProtocol, @unchecked Send
|
|||
self.lock.withLock { self.acceptHeader }
|
||||
}
|
||||
|
||||
static var requestURLs: [URL] {
|
||||
self.lock.withLock { self.urls }
|
||||
}
|
||||
|
||||
static func set(_ stub: Stub) {
|
||||
self.lock.withLock {
|
||||
self.stub = stub
|
||||
self.handler = nil
|
||||
self.requests = 0
|
||||
self.acceptHeader = nil
|
||||
self.urls = []
|
||||
}
|
||||
}
|
||||
|
||||
static func set(_ handler: @escaping @Sendable (URLRequest) -> Stub) {
|
||||
self.lock.withLock {
|
||||
self.handler = handler
|
||||
self.requests = 0
|
||||
self.acceptHeader = nil
|
||||
self.urls = []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +473,10 @@ private final class ChatLinkPreviewStubURLProtocol: URLProtocol, @unchecked Send
|
|||
let stub = Self.lock.withLock { () -> Stub in
|
||||
Self.requests += 1
|
||||
Self.acceptHeader = self.request.value(forHTTPHeaderField: "Accept")
|
||||
return Self.stub
|
||||
if let url = self.request.url {
|
||||
Self.urls.append(url)
|
||||
}
|
||||
return Self.handler?(self.request) ?? Self.stub
|
||||
}
|
||||
switch stub {
|
||||
case let .response(headers, data):
|
||||
|
|
@ -282,9 +501,58 @@ private final class ChatLinkPreviewStubURLProtocol: URLProtocol, @unchecked Send
|
|||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
private final class ChatLinkPreviewHangingURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
override class func canInit(with _: URLRequest) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override func startLoading() {}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
extension ChatLinkPreviewResult {
|
||||
fileprivate var metadata: ChatLinkPreviewMetadata? {
|
||||
if case let .loaded(metadata) = self { return metadata }
|
||||
if case let .loaded(metadata) = self {
|
||||
return metadata
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatLinkPreviewImageResult {
|
||||
fileprivate var thumbnail: ChatLinkPreviewThumbnail? {
|
||||
if case let .loaded(thumbnail) = self {
|
||||
return thumbnail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeChatLinkPreviewPNG(width: Int, height: Int) throws -> Data {
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
let context = try #require(CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo))
|
||||
context.setFillColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1)
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
let image = try #require(context.makeImage())
|
||||
let data = NSMutableData()
|
||||
let destination = try #require(CGImageDestinationCreateWithData(
|
||||
data,
|
||||
UTType.png.identifier as CFString,
|
||||
1,
|
||||
nil))
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
#expect(CGImageDestinationFinalize(destination))
|
||||
return data as Data
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue