From 508296f1ed9e929d392b8d27558ccf3572f22ccb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 10:27:08 +0100 Subject: [PATCH] feat(ios): og:image thumbnails on link preview cards (#101387) --- .../OpenClawChatUI/ChatLinkPreview.swift | 287 +++++++++++++-- .../ChatLinkPreviewTests.swift | 328 ++++++++++++++++-- 2 files changed, 551 insertions(+), 64 deletions(-) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatLinkPreview.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatLinkPreview.swift index edc45a771de..b476e020860 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatLinkPreview.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatLinkPreview.swift @@ -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 { + 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? 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) { @@ -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) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift index aec3e862d96..b45e1997756 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift @@ -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("".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("".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( + "" + .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 +}