feat(ios): og:image thumbnails on link preview cards (#101387)

This commit is contained in:
Peter Steinberger 2026-07-07 10:27:08 +01:00 committed by GitHub
parent bc5a82b26d
commit 508296f1ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 551 additions and 64 deletions

View file

@ -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)

View file

@ -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
}