summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/Extensions/Bundle+UserAgent.swift14
-rw-r--r--Hutch/Networking/HutchStatsService.swift5
-rw-r--r--Hutch/Networking/ManPageService.swift4
-rw-r--r--Hutch/Networking/SRHTClient.swift5
-rw-r--r--Hutch/Networking/SystemStatusService.swift9
-rw-r--r--HutchTests/BundleUserAgentTests.swift130
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)
+ }
+}