summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Sora/Data/Booru/BooruManager.swift40
-rw-r--r--Sora/Data/Booru/BooruNetworkImageLoader.swift61
-rw-r--r--Sora/Data/Booru/BooruRequestConfiguration.swift66
-rw-r--r--Sora/Data/ImageCacheManager.swift59
-rw-r--r--Sora/Views/FavoritePostThumbnailView.swift8
-rw-r--r--Sora/Views/Post/Details/Carousel/PostDetailsCarouselView.swift11
-rw-r--r--Sora/Views/Post/Details/PostDetailsImageView.swift33
-rw-r--r--Sora/Views/Post/Grid/PostGridThumbnailView.swift8
-rw-r--r--SoraTests/ViewDerivedDataTests.swift131
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."
)
}