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