From 2b62f719849cce5dd43fef1b679fff3a2243fcdf Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 22 Mar 2026 13:20:36 +0000 Subject: feat: add configurable booru user agent settings --- Localizable.xcstrings | 38 ++++++ Sora/Data/Booru/BooruManager.swift | 38 +++++- Sora/Data/Settings/SettingsManager.swift | 6 + Sora/Views/MainView.swift | 10 +- .../Section/SettingsSectionProviderView.swift | 13 ++ 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 @@ -176,6 +176,9 @@ "Custom Providers" : { "comment" : "A section in the settings menu that allows users to manage their custom booru providers.", "isCommentAutoGenerated" : true + }, + "Custom User-Agent" : { + }, "Debug" : { @@ -259,6 +262,9 @@ } } } + }, + "Favorite post %@" : { + }, "Favorited" : { "comment" : "A label indicating that an item is marked as \"favorited\".", @@ -322,6 +328,9 @@ }, "Lazy Thumbnail Loading" : { + }, + "Leave this field empty to use Sora's default User-Agent." : { + }, "Load Next Page" : { @@ -425,6 +434,9 @@ "Performance" : { "comment" : "A section header in the settings view related to performance settings.", "isCommentAutoGenerated" : true + }, + "Post %@" : { + }, "Posts" : { "localizations" : { @@ -474,6 +486,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." : { @@ -567,6 +599,9 @@ }, "Select a Post" : { + }, + "Send User-Agent" : { + }, "Settings" : { "localizations" : { @@ -678,6 +713,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() // 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( -- cgit v1.2.3