diff options
| author | Fuwn <[email protected]> | 2026-03-22 13:20:36 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-03-22 13:20:36 +0000 |
| commit | 2b62f719849cce5dd43fef1b679fff3a2243fcdf (patch) | |
| tree | cf49424478aa71fcfb8a94b9af4a7b3b01f49908 | |
| parent | perf: cache danbooru page token floor and reuse search-history payloads (diff) | |
| download | sora-testing-2b62f719849cce5dd43fef1b679fff3a2243fcdf.tar.xz sora-testing-2b62f719849cce5dd43fef1b679fff3a2243fcdf.zip | |
feat: add configurable booru user agent settings
| -rw-r--r-- | Localizable.xcstrings | 38 | ||||
| -rw-r--r-- | Sora/Data/Booru/BooruManager.swift | 38 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsManager.swift | 6 | ||||
| -rw-r--r-- | Sora/Views/MainView.swift | 10 | ||||
| -rw-r--r-- | Sora/Views/Settings/Section/SettingsSectionProviderView.swift | 13 | ||||
| -rw-r--r-- | SoraTests/ViewDerivedDataTests.swift | 135 |
6 files changed, 232 insertions, 8 deletions
diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 88a466e..3315e3a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -177,6 +177,9 @@ "comment" : "A section in the settings menu that allows users to manage their custom booru providers.", "isCommentAutoGenerated" : true }, + "Custom User-Agent" : { + + }, "Debug" : { }, @@ -260,6 +263,9 @@ } } }, + "Favorite post %@" : { + + }, "Favorited" : { "comment" : "A label indicating that an item is marked as \"favorited\".", "isCommentAutoGenerated" : true, @@ -323,6 +329,9 @@ "Lazy Thumbnail Loading" : { }, + "Leave this field empty to use Sora's default User-Agent." : { + + }, "Load Next Page" : { }, @@ -426,6 +435,9 @@ "comment" : "A section header in the settings view related to performance settings.", "isCommentAutoGenerated" : true }, + "Post %@" : { + + }, "Posts" : { "localizations" : { "ja" : { @@ -475,6 +487,26 @@ "Rabbit SVG created by Kim Sun Young" : { }, + "Rating %@. Provider %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Rating %1$@. Provider %2$@." + } + } + } + }, + "Rating %@. Score %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Rating %1$@. Score %2$@." + } + } + } + }, "Recent searches will appear here." : { }, @@ -568,6 +600,9 @@ "Select a Post" : { }, + "Send User-Agent" : { + + }, "Settings" : { "localizations" : { "ja" : { @@ -679,6 +714,9 @@ "User ID" : { }, + "User-Agent" : { + + }, "Visit Count" : { }, diff --git a/Sora/Data/Booru/BooruManager.swift b/Sora/Data/Booru/BooruManager.swift index 2957fcd..68d5079 100644 --- a/Sora/Data/Booru/BooruManager.swift +++ b/Sora/Data/Booru/BooruManager.swift @@ -26,7 +26,7 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng private let pageCache = NSCache<NSString, BooruPageCacheEntry>() // swiftlint:disable:this legacy_objc_type private let cacheDuration: TimeInterval private let credentials: BooruProviderCredentials? - private let userAgent: String + private let userAgent: String? private let showHeldMoebooruPosts: Bool private var urlCache: [String: URL] = [:] private var lastPostCount = 0 @@ -48,6 +48,8 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng _ provider: BooruProvider, credentials: BooruProviderCredentials? = nil, cacheDuration: TimeInterval = BooruPageCacheEntry.defaultExpiration, + sendUserAgent: Bool = true, + customUserAgent: String = "", showHeldMoebooruPosts: Bool = false ) { self.provider = provider @@ -59,10 +61,10 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng pageCache.countLimit = 50 pageCache.totalCostLimit = 50 * 1_024 * 1_024 - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" - - self.userAgent = "Sora/\(version) (Build \(buildNumber))" + self.userAgent = Self.resolvedUserAgent( + sendUserAgent: sendUserAgent, + customUserAgent: customUserAgent + ) let rootQuery = BooruSearchQuery( provider: provider, @@ -198,6 +200,26 @@ 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))" + } + func clearCachedPages() { pageCache.removeAllObjects() urlCache.removeAll() @@ -571,7 +593,11 @@ class BooruManager: ObservableObject { // swiftlint:disable:this type_body_leng } func requestURL(_ url: URL) async throws -> Data { - try await AF.request(url, headers: ["User-Agent": userAgent]) + let headers = userAgent.map { value in + HTTPHeaders([HTTPHeader(name: "User-Agent", value: value)]) + } + + return try await AF.request(url, headers: headers) .validate(statusCode: 200..<300) .serializingData() .value diff --git a/Sora/Data/Settings/SettingsManager.swift b/Sora/Data/Settings/SettingsManager.swift index 78aef7c..f59db1a 100644 --- a/Sora/Data/Settings/SettingsManager.swift +++ b/Sora/Data/Settings/SettingsManager.swift @@ -38,6 +38,12 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l @AppStorage("showHeldMoebooruPosts") var showHeldMoebooruPosts = false + @AppStorage("sendBooruUserAgent") + var sendBooruUserAgent = true + + @AppStorage("customBooruUserAgent") + var customBooruUserAgent = "" + private var syncObservation: NSObjectProtocol? #if os(macOS) diff --git a/Sora/Views/MainView.swift b/Sora/Views/MainView.swift index 770d424..603f7a1 100644 --- a/Sora/Views/MainView.swift +++ b/Sora/Views/MainView.swift @@ -18,8 +18,10 @@ struct MainView: View { } } .onAppear(perform: initializeManager) - .onChange(of: settings.providerCredentials) { initializeManager() } - .onChange(of: settings.showHeldMoebooruPosts) { initializeManager() } + .onChange(of: settings.providerCredentials) { updateManager(settings.preferredBooru) } + .onChange(of: settings.showHeldMoebooruPosts) { updateManager(settings.preferredBooru) } + .onChange(of: settings.sendBooruUserAgent) { updateManager(settings.preferredBooru) } + .onChange(of: settings.customBooruUserAgent) { updateManager(settings.preferredBooru) } #if os(macOS) .onChange(of: selectedTab) { _, newValue in if newValue == 0 { @@ -121,6 +123,8 @@ struct MainView: View { provider, credentials: settings.providerCredentials .first { $0.provider == settings.preferredBooru }, + sendUserAgent: settings.sendBooruUserAgent, + customUserAgent: settings.customBooruUserAgent, showHeldMoebooruPosts: settings.showHeldMoebooruPosts ) manager.searchText = previousSearchText @@ -139,6 +143,8 @@ struct MainView: View { settings.preferredBooru, credentials: settings.providerCredentials .first { $0.provider == settings.preferredBooru }, + sendUserAgent: settings.sendBooruUserAgent, + customUserAgent: settings.customBooruUserAgent, showHeldMoebooruPosts: settings.showHeldMoebooruPosts ) diff --git a/Sora/Views/Settings/Section/SettingsSectionProviderView.swift b/Sora/Views/Settings/Section/SettingsSectionProviderView.swift index cbfae37..031840a 100644 --- a/Sora/Views/Settings/Section/SettingsSectionProviderView.swift +++ b/Sora/Views/Settings/Section/SettingsSectionProviderView.swift @@ -26,6 +26,19 @@ struct SettingsSectionProviderView: View { Toggle("Show Held Posts", isOn: $settings.showHeldMoebooruPosts) } + Section(header: Text("User-Agent")) { + Toggle("Send User-Agent", isOn: $settings.sendBooruUserAgent) + + if settings.sendBooruUserAgent { + TextField("Custom User-Agent", text: $settings.customBooruUserAgent) + .autocorrectionDisabled(true) + + Text("Leave this field empty to use Sora's default User-Agent.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Section(header: Text("API Credentials")) { SecureField( "API Key", diff --git a/SoraTests/ViewDerivedDataTests.swift b/SoraTests/ViewDerivedDataTests.swift index d19b51a..f844a7c 100644 --- a/SoraTests/ViewDerivedDataTests.swift +++ b/SoraTests/ViewDerivedDataTests.swift @@ -738,6 +738,32 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b ) } + func testSettingsManagerPersistsBooruUserAgentSettings() throws { + let source = try loadSource(at: "Sora/Data/Settings/SettingsManager.swift") + let sendUserAgentSettingCount = tokenCount( + matching: #"\@AppStorage\("sendBooruUserAgent"\)\s*var\s+sendBooruUserAgent\s*=\s*true"#, + in: source + ) + let customUserAgentSettingCount = tokenCount( + matching: + #"\@AppStorage\("customBooruUserAgent"\)\s*var\s+customBooruUserAgent\s*=\s*"""#, + in: source + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + sendUserAgentSettingCount, + 0, + "SettingsManager should persist a booru User-Agent toggle with a default of true." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + customUserAgentSettingCount, + 0, + "SettingsManager should persist a custom booru User-Agent override." + ) + } + func testMainViewPassesShowHeldMoebooruPostsToBooruManager() throws { let source = try loadSource(at: "Sora/Views/MainView.swift") let managerShowHeldWiringCount = tokenCount( @@ -763,6 +789,51 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b ) } + func testMainViewPassesBooruUserAgentSettingsToBooruManager() throws { + let source = try loadSource(at: "Sora/Views/MainView.swift") + let sendUserAgentWiringCount = tokenCount( + matching: #"sendUserAgent:\s*settings\.sendBooruUserAgent"#, + in: source + ) + let customUserAgentWiringCount = tokenCount( + matching: #"customUserAgent:\s*settings\.customBooruUserAgent"#, + in: source + ) + let sendUserAgentChangeObserverCount = tokenCount( + matching: #"\.onChange\s*\(\s*of:\s*settings\.sendBooruUserAgent\s*\)"#, + in: source + ) + let customUserAgentChangeObserverCount = tokenCount( + matching: #"\.onChange\s*\(\s*of:\s*settings\.customBooruUserAgent\s*\)"#, + in: source + ) + + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + sendUserAgentWiringCount, + 1, + "MainView should wire booru User-Agent toggle into all BooruManager reconstructions." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + customUserAgentWiringCount, + 1, + "MainView should wire booru custom User-Agent into all BooruManager reconstructions." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + sendUserAgentChangeObserverCount, + 0, + "MainView should rebuild BooruManager when booru User-Agent sending changes." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + customUserAgentChangeObserverCount, + 0, + "MainView should rebuild BooruManager when custom booru User-Agent changes." + ) + } + func testBooruManagerSupportsShowHeldMoebooruPostsMode() throws { let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") let initSignatureCount = tokenCount( @@ -1012,6 +1083,70 @@ 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 + ) + let userAgentResolverSection = try extractFunction( + named: "private static func resolvedUserAgent(", + from: source + ) + let requestURLSection = try extractFunction( + named: "func requestURL(_ url: URL) async throws -> Data", + from: source + ) + let disableBypassCount = tokenCount( + matching: #"guard\s+sendUserAgent\s+else\s*\{\s*return\s+nil\s*\}"#, + in: userAgentResolverSection + ) + let customUserAgentTrimCount = tokenCount( + matching: #"customUserAgent\.trimmingCharacters"#, + in: userAgentResolverSection + ) + let optionalHeaderMappingCount = tokenCount( + matching: #"userAgent\.map"#, + in: requestURLSection + ) + let explicitUserAgentHeaderCount = tokenCount( + matching: #"HTTPHeader\(name:\s*"User-Agent",\s*value:\s*value\)"#, + in: requestURLSection + ) + + // 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." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + customUserAgentTrimCount, + 0, + "BooruManager should normalize custom booru User-Agent values before use." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + optionalHeaderMappingCount, + 0, + "BooruManager should only attach request headers when a User-Agent is available." + ) + // swiftlint:disable:next prefer_nimble + XCTAssertGreaterThan( + explicitUserAgentHeaderCount, + 0, + "BooruManager should keep using the User-Agent header name for booru requests." + ) + } + func testBooruManagerDanbooruFallsBackToUnauthenticatedRequestsOnUnauthorized() throws { let source = try loadSource(at: "Sora/Data/Booru/BooruManager.swift") let retrySection = try extractFunction( |