diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/Extensions/Bundle+UserAgent.swift | 14 | ||||
| -rw-r--r-- | Hutch/Networking/HutchStatsService.swift | 5 | ||||
| -rw-r--r-- | Hutch/Networking/ManPageService.swift | 4 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 5 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusService.swift | 9 | ||||
| -rw-r--r-- | HutchTests/BundleUserAgentTests.swift | 130 |
7 files changed, 165 insertions, 18 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index c4c78df..d223d18 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -515,7 +515,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.7; + MARKETING_VERSION = 3.1.8; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +552,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.7; + MARKETING_VERSION = 3.1.8; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +632,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.7; + MARKETING_VERSION = 3.1.8; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +661,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.7; + MARKETING_VERSION = 3.1.8; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/Extensions/Bundle+UserAgent.swift b/Hutch/Extensions/Bundle+UserAgent.swift new file mode 100644 index 0000000..2200576 --- /dev/null +++ b/Hutch/Extensions/Bundle+UserAgent.swift @@ -0,0 +1,14 @@ +import Foundation + +extension Bundle { + /// The HTTP `User-Agent` string sent with all Hutch network requests. + /// + /// Format: `Hutch/<version>` + var hutchUserAgent: String { + let name = (object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? (object(forInfoDictionaryKey: "CFBundleName") as? String) + ?? "Hutch" + let version = (object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "dev" + return "\(name)/\(version)" + } +} diff --git a/Hutch/Networking/HutchStatsService.swift b/Hutch/Networking/HutchStatsService.swift index aead9c6..fbac5be 100644 --- a/Hutch/Networking/HutchStatsService.swift +++ b/Hutch/Networking/HutchStatsService.swift @@ -54,9 +54,12 @@ struct HutchStatsService: ContributionCalendarServing { throw URLError(.badURL) } + var request = URLRequest(url: url) + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") + let (data, response): (Data, URLResponse) do { - (data, response) = try await session.data(from: url) + (data, response) = try await session.data(for: request) } catch { throw SRHTError.networkError(error) } diff --git a/Hutch/Networking/ManPageService.swift b/Hutch/Networking/ManPageService.swift index 07d5c2d..4453572 100644 --- a/Hutch/Networking/ManPageService.swift +++ b/Hutch/Networking/ManPageService.swift @@ -18,7 +18,9 @@ struct ManPageService { throw URLError(.badURL) } - let (data, response) = try await URLSession.shared.data(from: url) + var request = URLRequest(url: url) + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") + let (data, response) = try await URLSession.shared.data(for: request) if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { throw URLError(.badServerResponse) diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index 70c7650..b4f8ee0 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -66,6 +66,7 @@ final class SRHTClient: Sendable { // Build request var request = URLRequest(url: service.url) request.httpMethod = "POST" + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -181,6 +182,7 @@ final class SRHTClient: Sendable { var request = URLRequest(url: service.url) request.httpMethod = "POST" + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") @@ -315,6 +317,7 @@ final class SRHTClient: Sendable { var request = URLRequest(url: service.url) request.httpMethod = "POST" + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") @@ -436,6 +439,7 @@ final class SRHTClient: Sendable { var request = URLRequest(url: service.url) request.httpMethod = "POST" + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -535,6 +539,7 @@ final class SRHTClient: Sendable { } var request = URLRequest(url: url) + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (data, response): (Data, URLResponse) diff --git a/Hutch/Networking/SystemStatusService.swift b/Hutch/Networking/SystemStatusService.swift index ec2d233..35b82a1 100644 --- a/Hutch/Networking/SystemStatusService.swift +++ b/Hutch/Networking/SystemStatusService.swift @@ -60,14 +60,7 @@ struct SystemStatusService: Sendable { return data } - private var userAgent: String { - let bundle = Bundle.main - let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) - ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) - ?? "Hutch" - let version = (bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "dev" - return "\(name)/\(version) (System Status)" - } + private var userAgent: String { Bundle.main.hutchUserAgent } } extension SystemStatusService: SystemStatusServing {} diff --git a/HutchTests/BundleUserAgentTests.swift b/HutchTests/BundleUserAgentTests.swift new file mode 100644 index 0000000..b2f701b --- /dev/null +++ b/HutchTests/BundleUserAgentTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import Hutch + +// MARK: - URLProtocol stub + +/// Captures outgoing URLRequests and returns a minimal 401 so callers fail fast +/// without touching the real network. +private final class CapturingURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var capturedRequests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + CapturingURLProtocol.capturedRequests.append(request) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [CapturingURLProtocol.self] + return URLSession(configuration: config) + } +} + +// MARK: - Tests + +/// Tests are serialized because CapturingURLProtocol uses shared static state. +@Suite(.serialized) +struct BundleUserAgentTests { + + // MARK: Bundle extension + + @Test + func hutchUserAgentHasNameSlashVersion() { + let ua = Bundle.main.hutchUserAgent + let parts = ua.split(separator: "/", maxSplits: 1) + #expect(parts.count == 2) + #expect(parts[0] == "Hutch") + #expect(!parts[1].isEmpty) + } + + @Test + func hutchUserAgentContainsNoParenthesizedContext() { + // The old SystemStatusService user-agent appended "(System Status)". + // The shared agent should be plain "Hutch/<version>". + #expect(!Bundle.main.hutchUserAgent.contains("(")) + } + + // MARK: SRHTClient + + @Test + func sRHTClientSetsUserAgentOnExecute() async { + CapturingURLProtocol.capturedRequests = [] + let client = SRHTClient(session: CapturingURLProtocol.makeSession(), token: "test-token") + + _ = try? await client.execute( + service: .builds, + query: "{ jobs { results { id } } }", + responseType: [String: String].self + ) + + guard let captured = CapturingURLProtocol.capturedRequests.first else { + Issue.record("No request was captured by SRHTClient.execute.") + return + } + #expect(captured.value(forHTTPHeaderField: "User-Agent") == Bundle.main.hutchUserAgent) + } + + @Test + func sRHTClientSetsUserAgentOnFetchText() async { + CapturingURLProtocol.capturedRequests = [] + let client = SRHTClient(session: CapturingURLProtocol.makeSession(), token: "test-token") + let url = try! #require(URL(string: "https://builds.sr.ht/~test/job/1/log")) + + _ = try? await client.fetchText(url: url) + + guard let captured = CapturingURLProtocol.capturedRequests.first else { + Issue.record("No request was captured by SRHTClient.fetchText.") + return + } + #expect(captured.value(forHTTPHeaderField: "User-Agent") == Bundle.main.hutchUserAgent) + } + + // MARK: SystemStatusService + + @Test + func systemStatusServiceSetsUserAgent() async { + CapturingURLProtocol.capturedRequests = [] + let service = SystemStatusService(session: CapturingURLProtocol.makeSession()) + + _ = try? await service.fetchSnapshotHTML() + + guard let captured = CapturingURLProtocol.capturedRequests.first else { + Issue.record("No request was captured by SystemStatusService.") + return + } + #expect(captured.value(forHTTPHeaderField: "User-Agent") == Bundle.main.hutchUserAgent) + } + + // MARK: HutchStatsService + + @Test + func hutchStatsServiceSetsUserAgent() async { + CapturingURLProtocol.capturedRequests = [] + let service = HutchStatsService( + session: CapturingURLProtocol.makeSession(), + configuration: AppConfiguration(environment: [:]) + ) + + _ = try? await service.fetchContributionCalendar(actor: "testuser", endingOn: .now) + + guard let captured = CapturingURLProtocol.capturedRequests.first else { + Issue.record("No request was captured by HutchStatsService.") + return + } + #expect(captured.value(forHTTPHeaderField: "User-Agent") == Bundle.main.hutchUserAgent) + } +} |
