krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

06c36c4352f47655cb39a5042a7e5fd475c118e1

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-15T20:29:57Z

feat: add custom hutch user-agent to api calls

Implements: https://todo.sr.ht/~ccleberg/hutch/64
 Hutch.xcodeproj/project.pbxproj            |  16 ++--
 Hutch/Extensions/Bundle+UserAgent.swift    |  14 ++++
 Hutch/Networking/HutchStatsService.swift   |   5 +-
 Hutch/Networking/ManPageService.swift      |   4 +-
 Hutch/Networking/SRHTClient.swift          |   5 ++
 Hutch/Networking/SystemStatusService.swift |   9 +-
 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)
+    }
+}