diff options
| -rw-r--r-- | Sora/Data/Booru/BooruManager.swift | 40 | ||||
| -rw-r--r-- | Sora/Data/Booru/BooruNetworkImageLoader.swift | 61 | ||||
| -rw-r--r-- | Sora/Data/Booru/BooruRequestConfiguration.swift | 66 | ||||
| -rw-r--r-- | Sora/Data/ImageCacheManager.swift | 59 | ||||
| -rw-r--r-- | Sora/Views/FavoritePostThumbnailView.swift | 8 | ||||
| -rw-r--r-- | Sora/Views/Post/Details/Carousel/PostDetailsCarouselView.swift | 11 | ||||
| -rw-r--r-- | Sora/Views/Post/Details/PostDetailsImageView.swift | 33 | ||||
| -rw-r--r-- | Sora/Views/Post/Grid/PostGridThumbnailView.swift | 8 | ||||
| -rw-r--r-- | SoraTests/ViewDerivedDataTests.swift | 131 |
9 files changed, 340 insertions, 77 deletions
diff --git a/Sora/Data/Booru/BooruManager.swift b/Sora/Data/Booru/BooruManager.swift index f5fcf89..6333453 100644 --- a/Sora/Data/Booru/BooruManager.swift +++ b/Sora/Data/Booru/BooruManager.swift @@ -62,8 +62,8 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng pageCache.countLimit = 50 pageCache.totalCostLimit = 50 * 1_024 * 1_024 - self.referer = Self.baseReferer(for: provider.domain) - self.userAgent = Self.resolvedUserAgent( + self.referer = BooruRequestConfiguration.baseReferer(for: provider.domain) + self.userAgent = BooruRequestConfiguration.resolvedUserAgent( sendUserAgent: sendUserAgent, customUserAgent: customUserAgent ) @@ -202,30 +202,6 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng return components.url } - private static func resolvedUserAgent( - sendUserAgent: Bool, - customUserAgent: String - ) -> String? { - guard sendUserAgent else { return nil } - - let trimmedCustomUserAgent = customUserAgent.trimmingCharacters( - in: .whitespacesAndNewlines - ) - - guard trimmedCustomUserAgent.isEmpty else { - return trimmedCustomUserAgent - } - - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" - - return "Sora/\(version) (Build \(buildNumber))" - } - - private static func baseReferer(for domain: String) -> String { - "https://\(domain)/" - } - func clearCachedPages() { pageCache.removeAllObjects() urlCache.removeAll() @@ -599,13 +575,13 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng } func requestURL(_ url: URL) async throws -> Data { - var headers = HTTPHeaders([HTTPHeader(name: "Referer", value: referer)]) - - if let userAgent { - headers.add(name: "User-Agent", value: userAgent) - } + let request = BooruRequestConfiguration.request( + url: url, + referer: referer, + userAgent: userAgent + ) - return try await AF.request(url, headers: headers) + return try await AF.request(request) .validate(statusCode: 200..<300) .serializingData() .value diff --git a/Sora/Data/Booru/BooruNetworkImageLoader.swift b/Sora/Data/Booru/BooruNetworkImageLoader.swift new file mode 100644 index 0000000..c2a0f8a --- /dev/null +++ b/Sora/Data/Booru/BooruNetworkImageLoader.swift @@ -0,0 +1,61 @@ +import CoreGraphics +import Foundation +import ImageIO +import NetworkImage + +actor BooruNetworkImageLoader: NetworkImageLoader { + private let domain: String + private let sendUserAgent: Bool + private let customUserAgent: String + private var ongoingTasks: [URL: Task<CGImage, Error>] = [:] + + init( + domain: String, + sendUserAgent: Bool, + customUserAgent: String + ) { + self.domain = domain + self.sendUserAgent = sendUserAgent + self.customUserAgent = customUserAgent + } + + func image(from url: URL) async throws -> CGImage { + if let task = ongoingTasks[url] { + return try await task.value + } + + let domain = self.domain + let sendUserAgent = self.sendUserAgent + let customUserAgent = self.customUserAgent + + let task = Task<CGImage, Error> { + guard let data = await ImageCacheManager.shared.loadImageData( + for: url, + domain: domain, + sendUserAgent: sendUserAgent, + customUserAgent: customUserAgent + ) else { + throw URLError(.badServerResponse) + } + + guard + let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateImageAtIndex( + source, + 0, + [kCGImageSourceShouldCache: true] as CFDictionary + ) + else { + throw URLError(.cannotDecodeContentData) + } + + return image + } + + ongoingTasks[url] = task + + defer { ongoingTasks.removeValue(forKey: url) } + + return try await task.value + } +} diff --git a/Sora/Data/Booru/BooruRequestConfiguration.swift b/Sora/Data/Booru/BooruRequestConfiguration.swift new file mode 100644 index 0000000..2b786cf --- /dev/null +++ b/Sora/Data/Booru/BooruRequestConfiguration.swift @@ -0,0 +1,66 @@ +import Foundation + +enum BooruRequestConfiguration { + static func resolvedUserAgent( + sendUserAgent: Bool, + customUserAgent: String + ) -> String? { + guard sendUserAgent else { return nil } + + let trimmedCustomUserAgent = customUserAgent.trimmingCharacters( + in: .whitespacesAndNewlines + ) + + guard trimmedCustomUserAgent.isEmpty else { + return trimmedCustomUserAgent + } + + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + + return "Sora/\(version) (Build \(buildNumber))" + } + + static func baseReferer(for domain: String) -> String { + "https://\(domain)/" + } + + static func request( + url: URL, + referer: String, + userAgent: String?, + accept: String? = nil + ) -> URLRequest { + var request = URLRequest(url: url) + + request.setValue(referer, forHTTPHeaderField: "Referer") + + if let userAgent { + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + } + + if let accept { + request.setValue(accept, forHTTPHeaderField: "Accept") + } + + return request + } + + static func request( + url: URL, + domain: String, + sendUserAgent: Bool, + customUserAgent: String, + accept: String? = nil + ) -> URLRequest { + request( + url: url, + referer: baseReferer(for: domain), + userAgent: resolvedUserAgent( + sendUserAgent: sendUserAgent, + customUserAgent: customUserAgent + ), + accept: accept + ) + } +} diff --git a/Sora/Data/ImageCacheManager.swift b/Sora/Data/ImageCacheManager.swift index 5561f24..c5a4c6e 100644 --- a/Sora/Data/ImageCacheManager.swift +++ b/Sora/Data/ImageCacheManager.swift @@ -35,9 +35,22 @@ final class ImageCacheManager { } // MARK: - Public Methods - func preloadImages(_ urls: [URL]) { + func preloadImages( + _ urls: [URL], + domain: String, + sendUserAgent: Bool, + customUserAgent: String + ) { let newURLs = urls.filter { url in - !preloadingURLs.contains(url) && cache.cachedResponse(for: URLRequest(url: url)) == nil + let request = BooruRequestConfiguration.request( + url: url, + domain: domain, + sendUserAgent: sendUserAgent, + customUserAgent: customUserAgent, + accept: "image/*" + ) + + return !preloadingURLs.contains(url) && cache.cachedResponse(for: request) == nil } for url in newURLs { @@ -47,8 +60,15 @@ final class ImageCacheManager { guard let self else { return } let completionSignal = DispatchSemaphore(value: 0) - - URLSession.shared.dataTask(with: url) { data, response, _ in + let request = BooruRequestConfiguration.request( + url: url, + domain: domain, + sendUserAgent: sendUserAgent, + customUserAgent: customUserAgent, + accept: "image/*" + ) + + URLSession.shared.dataTask(with: request) { data, response, _ in Task { @MainActor [weak self] in guard let self else { completionSignal.signal() @@ -65,7 +85,7 @@ final class ImageCacheManager { storeCachedResponse( data: data, response: response, - for: url + for: request ) } } @@ -81,29 +101,42 @@ final class ImageCacheManager { preloadingURLs.removeAll() } - func storeCachedResponse(data: Data, response: URLResponse, for url: URL) { + func storeCachedResponse(data: Data, response: URLResponse, for request: URLRequest) { cache.storeCachedResponse( CachedURLResponse(response: response, data: data), - for: URLRequest(url: url) + for: request ) } - func getCachedResponse(for url: URL) -> CachedURLResponse? { - cache.cachedResponse(for: URLRequest(url: url)) + func getCachedResponse(for request: URLRequest) -> CachedURLResponse? { + cache.cachedResponse(for: request) } - func loadImageData(for url: URL) async -> Data? { - if let cachedResponse = getCachedResponse(for: url) { + func loadImageData( + for url: URL, + domain: String, + sendUserAgent: Bool, + customUserAgent: String + ) async -> Data? { + let request = BooruRequestConfiguration.request( + url: url, + domain: domain, + sendUserAgent: sendUserAgent, + customUserAgent: customUserAgent, + accept: "image/*" + ) + + if let cachedResponse = getCachedResponse(for: request) { return cachedResponse.data } do { - let (data, response) = try await URLSession.shared.data(from: url) + let (data, response) = try await URLSession.shared.data(for: request) storeCachedResponse( data: data, response: response, - for: url + for: request ) return data diff --git a/Sora/Views/FavoritePostThumbnailView.swift b/Sora/Views/FavoritePostThumbnailView.swift index 661f34a..41113b9 100644 --- a/Sora/Views/FavoritePostThumbnailView.swift +++ b/Sora/Views/FavoritePostThumbnailView.swift @@ -5,6 +5,13 @@ struct FavoritePostThumbnailView: View { @EnvironmentObject var settings: SettingsManager let favorite: SettingsFavoritePost let onRemove: () -> Void + private var networkImageLoader: BooruNetworkImageLoader { + BooruNetworkImageLoader( + domain: favorite.provider.domain, + sendUserAgent: settings.sendBooruUserAgent, + customUserAgent: settings.customBooruUserAgent + ) + } private var thumbnailURL: URL? { switch settings.thumbnailQuality { @@ -55,6 +62,7 @@ struct FavoritePostThumbnailView: View { } placeholder: { PostGridThumbnailPlaceholderView() } + .networkImageLoader(networkImageLoader) } } diff --git a/Sora/Views/Post/Details/Carousel/PostDetailsCarouselView.swift b/Sora/Views/Post/Details/Carousel/PostDetailsCarouselView.swift index f733a89..e43be47 100644 --- a/Sora/Views/Post/Details/Carousel/PostDetailsCarouselView.swift +++ b/Sora/Views/Post/Details/Carousel/PostDetailsCarouselView.swift @@ -14,7 +14,9 @@ struct PostDetailsCarouselView: View { posts: [BooruPost], loadingStage: Binding<BooruPostLoadingState>, focusedPost: BooruPost? = nil, - onFocusedPostChange: @escaping (BooruPost) -> Void = { _ in } + onFocusedPostChange: @escaping (BooruPost) -> Void = { _ in + // Default no-op callback for previews and callers that don't need focus updates. + } ) { self.posts = posts self.focusedPost = focusedPost @@ -101,7 +103,12 @@ struct PostDetailsCarouselView: View { urlsToPreload.append(posts[index].previewURL) } - cacheManager.preloadImages(urlsToPreload) + cacheManager.preloadImages( + urlsToPreload, + domain: manager.domain, + sendUserAgent: settings.sendBooruUserAgent, + customUserAgent: settings.customBooruUserAgent + ) } private func syncCurrentIndexWithFocus() { diff --git a/Sora/Views/Post/Details/PostDetailsImageView.swift b/Sora/Views/Post/Details/PostDetailsImageView.swift index 1e0da72..fd61afd 100644 --- a/Sora/Views/Post/Details/PostDetailsImageView.swift +++ b/Sora/Views/Post/Details/PostDetailsImageView.swift @@ -2,7 +2,7 @@ import NetworkImage import SwiftUI import UserNotifications -struct PostDetailsImageView<Placeholder: View>: View { +struct PostDetailsImageView<Placeholder: View>: View { // swiftlint:disable:this type_body_length @EnvironmentObject var settings: SettingsManager @EnvironmentObject var manager: BooruManager var url: URL? @@ -10,6 +10,13 @@ struct PostDetailsImageView<Placeholder: View>: View { var finalLoadingState: BooruPostLoadingState let placeholder: () -> Placeholder let post: BooruPost? + private var networkImageLoader: BooruNetworkImageLoader { + BooruNetworkImageLoader( + domain: manager.domain, + sendUserAgent: settings.sendBooruUserAgent, + customUserAgent: settings.customBooruUserAgent + ) + } #if os(iOS) var keyWindow: UIWindow? { @@ -53,7 +60,12 @@ struct PostDetailsImageView<Placeholder: View>: View { guard let imageURL = url else { return } Task(priority: .userInitiated) { - guard let imageData = await ImageCacheManager.shared.loadImageData(for: imageURL), + guard let imageData = await ImageCacheManager.shared.loadImageData( + for: imageURL, + domain: manager.domain, + sendUserAgent: settings.sendBooruUserAgent, + customUserAgent: settings.customBooruUserAgent + ), let uiImage = UIImage(data: imageData) else { return } @@ -79,7 +91,12 @@ struct PostDetailsImageView<Placeholder: View>: View { #if os(iOS) Task(priority: .userInitiated) { guard let imageURL = url else { return } - guard let imageData = await ImageCacheManager.shared.loadImageData(for: imageURL), + guard let imageData = await ImageCacheManager.shared.loadImageData( + for: imageURL, + domain: manager.domain, + sendUserAgent: settings.sendBooruUserAgent, + customUserAgent: settings.customBooruUserAgent + ), let uiImage = UIImage(data: imageData) else { return } @@ -125,6 +142,7 @@ struct PostDetailsImageView<Placeholder: View>: View { placeholder() .onAppear { loadingState = .loadingPreview } } + .networkImageLoader(networkImageLoader) #if os(macOS) return content.overlay( @@ -214,8 +232,15 @@ struct PostDetailsImageView<Placeholder: View>: View { let detailViewQuality = settings.detailViewQuality let saveTagsToFile = settings.saveTagsToFile let post = self.post + let request = BooruRequestConfiguration.request( + url: url, + domain: manager.domain, + sendUserAgent: settings.sendBooruUserAgent, + customUserAgent: settings.customBooruUserAgent, + accept: "image/*" + ) - URLSession.shared.dataTask(with: url) { data, _, _ in + URLSession.shared.dataTask(with: request) { data, _, _ in guard let data, let post else { return } let picturesURL = FileManager.default.homeDirectoryForCurrentUser diff --git a/Sora/Views/Post/Grid/PostGridThumbnailView.swift b/Sora/Views/Post/Grid/PostGridThumbnailView.swift index 5316731..5f7829e 100644 --- a/Sora/Views/Post/Grid/PostGridThumbnailView.swift +++ b/Sora/Views/Post/Grid/PostGridThumbnailView.swift @@ -10,6 +10,13 @@ struct PostGridThumbnailView: View { let endOfData: Bool let onLoadNextPage: () async -> Void let selectedPost: BooruPost? + private var networkImageLoader: BooruNetworkImageLoader { + BooruNetworkImageLoader( + domain: manager.domain, + sendUserAgent: settings.sendBooruUserAgent, + customUserAgent: settings.customBooruUserAgent + ) + } private var thumbnailURL: URL? { switch settings.thumbnailQuality { @@ -74,6 +81,7 @@ struct PostGridThumbnailView: View { } placeholder: { PostGridThumbnailPlaceholderView() } + .networkImageLoader(networkImageLoader) } } } diff --git a/SoraTests/ViewDerivedDataTests.swift b/SoraTests/ViewDerivedDataTests.swift index 28fd59e..f737e1a 100644 --- a/SoraTests/ViewDerivedDataTests.swift +++ b/SoraTests/ViewDerivedDataTests.swift @@ -1083,23 +1083,18 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b ) } - func testBooruManagerSupportsOptionalAndCustomUserAgentHeaders() throws { - let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") - let initSignatureCount = tokenCount( - matching: - #"sendUserAgent:\s*Bool\s*=\s*true,\s*customUserAgent:\s*String\s*=\s*"""#, - in: source - ) + func testBooruRequestConfigurationSupportsOptionalAndCustomUserAgentHeaders() throws { + let source = try loadSource(at: "Sora/Data/Booru/BooruRequestConfiguration.swift") let userAgentResolverSection = try extractFunction( - named: "private static func resolvedUserAgent(", + named: "static func resolvedUserAgent(", from: source ) let refererResolverSection = try extractFunction( - named: "private static func baseReferer(for domain: String) -> String", + named: "static func baseReferer(for domain: String) -> String", from: source ) - let requestURLSection = try extractFunction( - named: "func requestURL(_ url: URL) async throws -> Data", + let requestBuilderSection = try extractFunction( + named: "static func request(", from: source ) let disableBypassCount = tokenCount( @@ -1115,49 +1110,133 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b in: refererResolverSection ) let refererHeaderCount = tokenCount( - matching: #"HTTPHeader\(name:\s*"Referer",\s*value:\s*referer\)"#, - in: requestURLSection + matching: #"request\.setValue\(referer,\s*forHTTPHeaderField:\s*"Referer"\)"#, + in: requestBuilderSection ) let explicitUserAgentHeaderCount = tokenCount( - matching: #"headers\.add\(name:\s*"User-Agent",\s*value:\s*userAgent\)"#, - in: requestURLSection + matching: #"request\.setValue\(userAgent,\s*forHTTPHeaderField:\s*"User-Agent"\)"#, + in: requestBuilderSection ) // swiftlint:disable:next prefer_nimble XCTAssertGreaterThan( - initSignatureCount, - 0, - "BooruManager should accept booru User-Agent enable and custom override options." - ) - // swiftlint:disable:next prefer_nimble - XCTAssertGreaterThan( disableBypassCount, 0, - "BooruManager should allow booru requests to omit the User-Agent header." + "Booru request configuration should allow booru requests to omit the User-Agent header." ) // swiftlint:disable:next prefer_nimble XCTAssertGreaterThan( customUserAgentTrimCount, 0, - "BooruManager should normalize custom booru User-Agent values before use." + "Booru request configuration should normalize custom booru User-Agent values before use." ) // swiftlint:disable:next prefer_nimble XCTAssertGreaterThan( refererFormatCount, 0, - "BooruManager should derive booru Referer headers from the provider base URL with a trailing slash." + "Booru request configuration should derive Referer headers from the provider base URL" + + " with a trailing slash." ) // swiftlint:disable:next prefer_nimble XCTAssertGreaterThan( refererHeaderCount, 0, - "BooruManager should always attach a Referer header for booru requests." + "Booru request configuration should always attach a Referer header for booru requests." ) // swiftlint:disable:next prefer_nimble XCTAssertGreaterThan( explicitUserAgentHeaderCount, 0, - "BooruManager should continue attaching the User-Agent header only when available." + "Booru request configuration should continue attaching the User-Agent header only when available." + ) + } + + func testBooruManagerUsesSharedRequestConfigurationForAPIRequests() throws { + let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") + let requestURLSection = try extractFunction( + named: "func requestURL(_ url: URL) async throws -> Data", + from: source + ) + let requestBuilderUsageCount = tokenCount( + matching: #"BooruRequestConfiguration\.request\("#, + in: requestURLSection + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + requestBuilderUsageCount, + 0, + "BooruManager should build API requests through shared booru request configuration." + ) + } + + func testImageCacheManagerUsesHeaderAwareImageRequests() throws { + let source = try loadSource(at: "Sora/Data/ImageCacheManager.swift") + let preloadSection = try extractFunction( + named: "func preloadImages(", + from: source + ) + let loadSection = try extractFunction( + named: "func loadImageData(", + from: source + ) + let requestBuilderUsageCount = tokenCount( + matching: #"BooruRequestConfiguration\.request\("#, + in: preloadSection + loadSection + ) + let dataTaskRequestCount = tokenCount( + matching: #"URLSession\.shared\.dataTask\(with:\s*request\)"#, + in: preloadSection + ) + let dataForRequestCount = tokenCount( + matching: #"URLSession\.shared\.data\(for:\s*request\)"#, + in: loadSection + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + requestBuilderUsageCount, + 1, + "ImageCacheManager should build image requests through shared booru request configuration." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + dataTaskRequestCount, + 0, + "Image preloading should send a header-aware URLRequest." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + dataForRequestCount, + 0, + "Image loading should send a header-aware URLRequest." + ) + } + + func testNetworkImageViewsUseBooruNetworkImageLoader() throws { + let postGridSource = try loadSource(at: "Sora/Views/Post/Grid/PostGridThumbnailView.swift") + let detailsSource = try loadSource(at: "Sora/Views/Post/Details/PostDetailsImageView.swift") + let favoritesSource = try loadSource(at: "Sora/Views/FavoritePostThumbnailView.swift") + let loaderUsageCount = tokenCount( + matching: #"\.networkImageLoader\(networkImageLoader\)"#, + in: postGridSource + detailsSource + favoritesSource + ) + let loaderConstructionCount = tokenCount( + matching: #"BooruNetworkImageLoader\("#, + in: postGridSource + detailsSource + favoritesSource + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + loaderUsageCount, + 2, + "Image views should inject a booru-aware network image loader so CDN requests include required headers." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + loaderConstructionCount, + 2, + "Image views should construct booru-aware loaders using the current provider domain and request settings." ) } |