Commit c4dd796588
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 75; | |
| 518 | CURRENT_PROJECT_VERSION = 76; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 3.1.7; | |
| 535 | MARKETING_VERSION = 3.1.8; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 75; | |
| 555 | CURRENT_PROJECT_VERSION = 76; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 3.1.7; | |
| 572 | MARKETING_VERSION = 3.1.8; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 75; | |
| 635 | CURRENT_PROJECT_VERSION = 76; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 3.1.7; | |
| 645 | MARKETING_VERSION = 3.1.8; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 75; | |
| 664 | CURRENT_PROJECT_VERSION = 76; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 3.1.7; | |
| 674 | MARKETING_VERSION = 3.1.8; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/Extensions/Bundle+UserAgent.swift added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | extension 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 { | ||
| 54 | 54 | throw URLError(.badURL) |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | var request = URLRequest(url: url) | |
| 58 | request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") | |
| 59 | ||
| 57 | 60 | let (data, response): (Data, URLResponse) |
| 58 | 61 | do { |
| 59 | (data, response) = try await session.data(from: url) | |
| 62 | (data, response) = try await session.data(for: request) | |
| 60 | 63 | } catch { |
| 61 | 64 | throw SRHTError.networkError(error) |
| 62 | 65 | } |
Hutch/Networking/ManPageService.swift +3 −1
| @@ -18,7 +18,9 @@ struct ManPageService { | ||
| 18 | 18 | throw URLError(.badURL) |
| 19 | 19 | } |
| 20 | 20 | |
| 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) | |
| 22 | 24 | if let http = response as? HTTPURLResponse, |
| 23 | 25 | !(200...299).contains(http.statusCode) { |
| 24 | 26 | throw URLError(.badServerResponse) |
Hutch/Networking/SRHTClient.swift +5
| @@ -66,6 +66,7 @@ final class SRHTClient: Sendable { | ||
| 66 | 66 | // Build request |
| 67 | 67 | var request = URLRequest(url: service.url) |
| 68 | 68 | request.httpMethod = "POST" |
| 69 | request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") | |
| 69 | 70 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") |
| 70 | 71 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 71 | 72 | |
| @@ -181,6 +182,7 @@ final class SRHTClient: Sendable { | ||
| 181 | 182 | |
| 182 | 183 | var request = URLRequest(url: service.url) |
| 183 | 184 | request.httpMethod = "POST" |
| 185 | request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") | |
| 184 | 186 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") |
| 185 | 187 | request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") |
| 186 | 188 | |
| @@ -315,6 +317,7 @@ final class SRHTClient: Sendable { | ||
| 315 | 317 | |
| 316 | 318 | var request = URLRequest(url: service.url) |
| 317 | 319 | request.httpMethod = "POST" |
| 320 | request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") | |
| 318 | 321 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") |
| 319 | 322 | request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") |
| 320 | 323 | |
| @@ -436,6 +439,7 @@ final class SRHTClient: Sendable { | ||
| 436 | 439 | |
| 437 | 440 | var request = URLRequest(url: service.url) |
| 438 | 441 | request.httpMethod = "POST" |
| 442 | request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") | |
| 439 | 443 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") |
| 440 | 444 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 441 | 445 | |
| @@ -535,6 +539,7 @@ final class SRHTClient: Sendable { | ||
| 535 | 539 | } |
| 536 | 540 | |
| 537 | 541 | var request = URLRequest(url: url) |
| 542 | request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") | |
| 538 | 543 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") |
| 539 | 544 | |
| 540 | 545 | let (data, response): (Data, URLResponse) |
Hutch/Networking/SystemStatusService.swift +1 −8
| @@ -60,14 +60,7 @@ struct SystemStatusService: Sendable { | ||
| 60 | 60 | return data |
| 61 | 61 | } |
| 62 | 62 | |
| 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 } | |
| 71 | 64 | } |
| 72 | 65 | |
| 73 | 66 | extension SystemStatusService: SystemStatusServing {} |
HutchTests/BundleUserAgentTests.swift added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 9 | private 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) | |
| 41 | struct 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 | } | |