summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-03-22 13:20:36 +0000
committerFuwn <[email protected]>2026-03-22 13:20:36 +0000
commit2b62f719849cce5dd43fef1b679fff3a2243fcdf (patch)
treecf49424478aa71fcfb8a94b9af4a7b3b01f49908
parentperf: cache danbooru page token floor and reuse search-history payloads (diff)
downloadsora-testing-2b62f719849cce5dd43fef1b679fff3a2243fcdf.tar.xz
sora-testing-2b62f719849cce5dd43fef1b679fff3a2243fcdf.zip
feat: add configurable booru user agent settings
-rw-r--r--Localizable.xcstrings38
-rw-r--r--Sora/Data/Booru/BooruManager.swift38
-rw-r--r--Sora/Data/Settings/SettingsManager.swift6
-rw-r--r--Sora/Views/MainView.swift10
-rw-r--r--Sora/Views/Settings/Section/SettingsSectionProviderView.swift13
-rw-r--r--SoraTests/ViewDerivedDataTests.swift135
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(