Commit c4dd796588

c4dd7965887831a1c8570b6d64d26a8dd1eb63cd

parent: da2eda73e4

Unsigned

cmc <hello@cleberg.net> · 2026-04-15 20:29 UTC

feat: add custom hutch user-agent to api calls

Implements: https://todo.sr.ht/~ccleberg/hutch/64

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 75;
518 CURRENT_PROJECT_VERSION = 76;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 3.1.7;
535 MARKETING_VERSION = 3.1.8;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 75;
555 CURRENT_PROJECT_VERSION = 76;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 3.1.7;
572 MARKETING_VERSION = 3.1.8;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 75;
635 CURRENT_PROJECT_VERSION = 76;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 3.1.7;
645 MARKETING_VERSION = 3.1.8;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 75;
664 CURRENT_PROJECT_VERSION = 76;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 3.1.7;
674 MARKETING_VERSION = 3.1.8;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Extensions/Bundle+UserAgent.swift added +14
@@ -0,0 +1,14 @@
1import Foundation
2
3extension Bundle {
4 /// The HTTP `User-Agent` string sent with all Hutch network requests.
5 ///
6 /// Format: `Hutch/<version>`
7 var hutchUserAgent: String {
8 let name = (object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
9 ?? (object(forInfoDictionaryKey: "CFBundleName") as? String)
10 ?? "Hutch"
11 let version = (object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "dev"
12 return "\(name)/\(version)"
13 }
14}
Hutch/Networking/HutchStatsService.swift +4 −1
@@ -54,9 +54,12 @@ struct HutchStatsService: ContributionCalendarServing {
5454 throw URLError(.badURL)
5555 }
5656
57 var request = URLRequest(url: url)
58 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
59
5760 let (data, response): (Data, URLResponse)
5861 do {
59 (data, response) = try await session.data(from: url)
62 (data, response) = try await session.data(for: request)
6063 } catch {
6164 throw SRHTError.networkError(error)
6265 }
Hutch/Networking/ManPageService.swift +3 −1
@@ -18,7 +18,9 @@ struct ManPageService {
1818 throw URLError(.badURL)
1919 }
2020
21 let (data, response) = try await URLSession.shared.data(from: url)
21 var request = URLRequest(url: url)
22 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
23 let (data, response) = try await URLSession.shared.data(for: request)
2224 if let http = response as? HTTPURLResponse,
2325 !(200...299).contains(http.statusCode) {
2426 throw URLError(.badServerResponse)
Hutch/Networking/SRHTClient.swift +5
@@ -66,6 +66,7 @@ final class SRHTClient: Sendable {
6666 // Build request
6767 var request = URLRequest(url: service.url)
6868 request.httpMethod = "POST"
69 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
6970 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
7071 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
7172
@@ -181,6 +182,7 @@ final class SRHTClient: Sendable {
181182
182183 var request = URLRequest(url: service.url)
183184 request.httpMethod = "POST"
185 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
184186 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
185187 request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
186188
@@ -315,6 +317,7 @@ final class SRHTClient: Sendable {
315317
316318 var request = URLRequest(url: service.url)
317319 request.httpMethod = "POST"
320 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
318321 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
319322 request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
320323
@@ -436,6 +439,7 @@ final class SRHTClient: Sendable {
436439
437440 var request = URLRequest(url: service.url)
438441 request.httpMethod = "POST"
442 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
439443 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
440444 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
441445
@@ -535,6 +539,7 @@ final class SRHTClient: Sendable {
535539 }
536540
537541 var request = URLRequest(url: url)
542 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
538543 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
539544
540545 let (data, response): (Data, URLResponse)
Hutch/Networking/SystemStatusService.swift +1 −8
@@ -60,14 +60,7 @@ struct SystemStatusService: Sendable {
6060 return data
6161 }
6262
63 private var userAgent: String {
64 let bundle = Bundle.main
65 let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
66 ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String)
67 ?? "Hutch"
68 let version = (bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "dev"
69 return "\(name)/\(version) (System Status)"
70 }
63 private var userAgent: String { Bundle.main.hutchUserAgent }
7164}
7265
7366extension SystemStatusService: SystemStatusServing {}
HutchTests/BundleUserAgentTests.swift added +130
@@ -0,0 +1,130 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5// MARK: - URLProtocol stub
6
7/// Captures outgoing URLRequests and returns a minimal 401 so callers fail fast
8/// without touching the real network.
9private final class CapturingURLProtocol: URLProtocol, @unchecked Sendable {
10 nonisolated(unsafe) static var capturedRequests: [URLRequest] = []
11
12 override class func canInit(with request: URLRequest) -> Bool { true }
13 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
14
15 override func startLoading() {
16 CapturingURLProtocol.capturedRequests.append(request)
17 let response = HTTPURLResponse(
18 url: request.url!,
19 statusCode: 401,
20 httpVersion: nil,
21 headerFields: nil
22 )!
23 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
24 client?.urlProtocol(self, didLoad: Data())
25 client?.urlProtocolDidFinishLoading(self)
26 }
27
28 override func stopLoading() {}
29
30 static func makeSession() -> URLSession {
31 let config = URLSessionConfiguration.ephemeral
32 config.protocolClasses = [CapturingURLProtocol.self]
33 return URLSession(configuration: config)
34 }
35}
36
37// MARK: - Tests
38
39/// Tests are serialized because CapturingURLProtocol uses shared static state.
40@Suite(.serialized)
41struct BundleUserAgentTests {
42
43 // MARK: Bundle extension
44
45 @Test
46 func hutchUserAgentHasNameSlashVersion() {
47 let ua = Bundle.main.hutchUserAgent
48 let parts = ua.split(separator: "/", maxSplits: 1)
49 #expect(parts.count == 2)
50 #expect(parts[0] == "Hutch")
51 #expect(!parts[1].isEmpty)
52 }
53
54 @Test
55 func hutchUserAgentContainsNoParenthesizedContext() {
56 // The old SystemStatusService user-agent appended "(System Status)".
57 // The shared agent should be plain "Hutch/<version>".
58 #expect(!Bundle.main.hutchUserAgent.contains("("))
59 }
60
61 // MARK: SRHTClient
62
63 @Test
64 func sRHTClientSetsUserAgentOnExecute() async {
65 CapturingURLProtocol.capturedRequests = []
66 let client = SRHTClient(session: CapturingURLProtocol.makeSession(), token: "test-token")
67
68 _ = try? await client.execute(
69 service: .builds,
70 query: "{ jobs { results { id } } }",
71 responseType: [String: String].self
72 )
73
74 guard let captured = CapturingURLProtocol.capturedRequests.first else {
75 Issue.record("No request was captured by SRHTClient.execute.")
76 return
77 }
78 #expect(captured.value(forHTTPHeaderField: "User-Agent") == Bundle.main.hutchUserAgent)
79 }
80
81 @Test
82 func sRHTClientSetsUserAgentOnFetchText() async {
83 CapturingURLProtocol.capturedRequests = []
84 let client = SRHTClient(session: CapturingURLProtocol.makeSession(), token: "test-token")
85 let url = try! #require(URL(string: "https://builds.sr.ht/~test/job/1/log"))
86
87 _ = try? await client.fetchText(url: url)
88
89 guard let captured = CapturingURLProtocol.capturedRequests.first else {
90 Issue.record("No request was captured by SRHTClient.fetchText.")
91 return
92 }
93 #expect(captured.value(forHTTPHeaderField: "User-Agent") == Bundle.main.hutchUserAgent)
94 }
95
96 // MARK: SystemStatusService
97
98 @Test
99 func systemStatusServiceSetsUserAgent() async {
100 CapturingURLProtocol.capturedRequests = []
101 let service = SystemStatusService(session: CapturingURLProtocol.makeSession())
102
103 _ = try? await service.fetchSnapshotHTML()
104
105 guard let captured = CapturingURLProtocol.capturedRequests.first else {
106 Issue.record("No request was captured by SystemStatusService.")
107 return
108 }
109 #expect(captured.value(forHTTPHeaderField: "User-Agent") == Bundle.main.hutchUserAgent)
110 }
111
112 // MARK: HutchStatsService
113
114 @Test
115 func hutchStatsServiceSetsUserAgent() async {
116 CapturingURLProtocol.capturedRequests = []
117 let service = HutchStatsService(
118 session: CapturingURLProtocol.makeSession(),
119 configuration: AppConfiguration(environment: [:])
120 )
121
122 _ = try? await service.fetchContributionCalendar(actor: "testuser", endingOn: .now)
123
124 guard let captured = CapturingURLProtocol.capturedRequests.first else {
125 Issue.record("No request was captured by HutchStatsService.")
126 return
127 }
128 #expect(captured.value(forHTTPHeaderField: "User-Agent") == Bundle.main.hutchUserAgent)
129 }
130}