Skip to content

Commit 9c13c31

Browse files
authored
feat: send coder User-Agent on all deployment requests (#264)
1 parent b63334a commit 9c13c31

6 files changed

Lines changed: 185 additions & 7 deletions

File tree

‎Coder-Desktop/Coder-DesktopHelper/Manager.swift‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ actor Manager {
4141
"Failed to create directories for binary destination (\(dest)): \(error.localizedDescription)"
4242
)
4343
}
44-
let client = Client(url: cfg.serverUrl, headers: cfg.literalHeaders)
44+
let client = Client(url: cfg.serverUrl, headers: cfg.literalHeaders, component: .helper)
4545
let buildInfo: BuildInfoResponse
4646
do {
4747
buildInfo = try await client.buildInfo()
@@ -68,7 +68,8 @@ actor Manager {
6868
src: binaryPath,
6969
dest: dest,
7070
urlSession: URLSession(configuration: sessionConfig),
71-
headers: cfg.literalHeaders
71+
headers: cfg.literalHeaders,
72+
component: .helper
7273
) { progress in
7374
pushProgress(stage: .downloading, downloadProgress: progress)
7475
}

‎Coder-Desktop/CoderSDK/Client.swift‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@ public struct Client: Sendable {
44
public let url: URL
55
public var token: String?
66
public var headers: [HTTPHeader]
7-
8-
public init(url: URL, token: String? = nil, headers: [HTTPHeader] = []) {
7+
public var component: CoderComponent
8+
9+
public init(
10+
url: URL,
11+
token: String? = nil,
12+
headers: [HTTPHeader] = [],
13+
component: CoderComponent = .app
14+
) {
915
self.url = url
1016
self.token = token
1117
self.headers = headers
18+
self.component = component
1219
}
1320

1421
func request(
@@ -25,6 +32,7 @@ public struct Client: Sendable {
2532
path: path,
2633
method: method,
2734
headers: headers,
35+
component: component,
2836
body: body
2937
)
3038
}
@@ -41,7 +49,8 @@ public struct Client: Sendable {
4149
baseURL: url,
4250
path: path,
4351
method: method,
44-
headers: headers
52+
headers: headers,
53+
component: component
4554
)
4655
}
4756
}
@@ -115,11 +124,13 @@ func doRequest(
115124
path: String,
116125
method: HTTPMethod,
117126
headers: [HTTPHeader] = [],
127+
component: CoderComponent = .app,
118128
body: Data? = nil
119129
) async throws(SDKError) -> HTTPResponse {
120130
let url = baseURL.appendingPathComponent(path)
121131
var req = URLRequest(url: url)
122132
req.httpMethod = method.rawValue
133+
req.setCoderUserAgent(component, unlessIn: headers)
123134
for header in headers {
124135
req.addValue(header.value, forHTTPHeaderField: header.name)
125136
}
@@ -142,6 +153,7 @@ func request(
142153
path: String,
143154
method: HTTPMethod,
144155
headers: [HTTPHeader] = [],
156+
component: CoderComponent = .app,
145157
body: some Encodable & Sendable
146158
) async throws(SDKError) -> HTTPResponse {
147159
let encodedBody: Data
@@ -155,6 +167,7 @@ func request(
155167
path: path,
156168
method: method,
157169
headers: headers,
170+
component: component,
158171
body: encodedBody
159172
)
160173
}
@@ -163,13 +176,15 @@ func request(
163176
baseURL: URL,
164177
path: String,
165178
method: HTTPMethod,
166-
headers: [HTTPHeader] = []
179+
headers: [HTTPHeader] = [],
180+
component: CoderComponent = .app
167181
) async throws(SDKError) -> HTTPResponse {
168182
try await doRequest(
169183
baseURL: baseURL,
170184
path: path,
171185
method: method,
172-
headers: headers
186+
headers: headers,
187+
component: component
173188
)
174189
}
175190

‎Coder-Desktop/CoderSDK/HTTP.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ enum HTTPMethod: String, Equatable, Hashable, Sendable {
2525

2626
enum Headers {
2727
static let sessionToken = "Coder-Session-Token"
28+
static let userAgent = "User-Agent"
2829
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Foundation
2+
3+
/// Identifies which Coder Desktop process is making a request.
4+
public enum CoderComponent: String, Sendable {
5+
case app = "coder-desktop"
6+
case helper = "coder-desktop-core"
7+
}
8+
9+
/// Go's `GOARCH` spelling, so a single pattern matches the CLI, the vpn-daemon and Desktop client.
10+
enum GoArch: String, Sendable {
11+
case arm64
12+
case amd64
13+
}
14+
15+
enum UserAgentDefaults {
16+
static let goos = "darwin"
17+
static let unknownVersion = "0.0.0"
18+
19+
#if arch(arm64)
20+
static let arch: GoArch = .arm64
21+
#elseif arch(x86_64)
22+
static let arch: GoArch = .amd64
23+
#endif
24+
25+
static var version: String {
26+
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? unknownVersion
27+
}
28+
}
29+
30+
/// Formats a `User-Agent` as `<token>/<version> (<goos>/<goarch>)`.
31+
func userAgent(
32+
component: CoderComponent,
33+
version: String = UserAgentDefaults.version,
34+
arch: GoArch = UserAgentDefaults.arch
35+
) -> String {
36+
"\(component.rawValue)/\(version) (\(UserAgentDefaults.goos)/\(arch.rawValue))"
37+
}
38+
39+
public extension URLRequest {
40+
/// Sets the Coder `User-Agent`, unless `headers` already carries one.
41+
mutating func setCoderUserAgent(_ component: CoderComponent, unlessIn headers: [HTTPHeader]) {
42+
let callerSuppliedUA = headers.contains {
43+
$0.name.caseInsensitiveCompare(Headers.userAgent) == .orderedSame
44+
}
45+
guard !callerSuppliedUA else { return }
46+
setValue(userAgent(component: component), forHTTPHeaderField: Headers.userAgent)
47+
}
48+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
@testable import CoderSDK
2+
import Foundation
3+
import Mocker
4+
import Testing
5+
6+
@Suite(.timeLimit(.minutes(1)))
7+
struct UserAgentTests {
8+
// The pattern operators are expected to match against every Coder client.
9+
static let grammar = #"^coder-desktop(-core)?/[0-9]+\.[0-9]+\.[0-9]+ \(darwin/(amd64|arm64)\)$"#
10+
11+
static let stubVersion = "0.8.4"
12+
static let stubServerVersion = "v2.18.2"
13+
14+
@Test
15+
func appToken() {
16+
#expect(
17+
userAgent(component: .app, version: Self.stubVersion, arch: .arm64)
18+
== "coder-desktop/\(Self.stubVersion) (darwin/arm64)"
19+
)
20+
}
21+
22+
@Test
23+
func helperToken() {
24+
#expect(
25+
userAgent(component: .helper, version: Self.stubVersion, arch: .arm64)
26+
== "coder-desktop-core/\(Self.stubVersion) (darwin/arm64)"
27+
)
28+
}
29+
30+
// The CLI and the vpn-daemon report `amd64`, so an Intel build must too.
31+
@Test
32+
func intelReportsGoArchNotSwiftArch() {
33+
let ua = userAgent(component: .app, version: Self.stubVersion, arch: .amd64)
34+
#expect(ua == "coder-desktop/\(Self.stubVersion) (darwin/amd64)")
35+
#expect(!ua.contains("x86_64"))
36+
}
37+
38+
@Test
39+
func matchesGrammar() throws {
40+
let regex = try Regex(Self.grammar)
41+
for component in [CoderComponent.app, .helper] {
42+
for arch in [GoArch.arm64, .amd64] {
43+
let ua = userAgent(component: component, version: Self.stubVersion, arch: arch)
44+
#expect(ua.contains(regex))
45+
}
46+
// The version the running bundle actually reports.
47+
#expect(userAgent(component: component).contains(regex))
48+
}
49+
}
50+
51+
@Test
52+
func missingVersionFallsBackToValidGrammar() throws {
53+
let regex = try Regex(Self.grammar)
54+
let ua = userAgent(
55+
component: .app,
56+
version: UserAgentDefaults.unknownVersion,
57+
arch: .arm64
58+
)
59+
#expect(ua == "coder-desktop/\(UserAgentDefaults.unknownVersion) (darwin/arm64)")
60+
#expect(ua.contains(regex))
61+
}
62+
63+
@Test
64+
func setOnRequest() async throws {
65+
// A distinct host per test: Mocker's registry is process-global and
66+
// suites run in parallel.
67+
let url = URL(string: "https://ua-default.example.com")!
68+
let client = Client(url: url, component: .helper)
69+
let sentUA = try await capturedUserAgent(client: client, url: url)
70+
71+
#expect(try sentUA.contains(Regex(Self.grammar)))
72+
#expect(sentUA.hasPrefix("coder-desktop-core/"))
73+
}
74+
75+
// A user-configured literal header must replace ours outright. Caller headers
76+
// are applied with `addValue`, which would otherwise comma-join the two.
77+
@Test
78+
func callerSuppliedUserAgentWins() async throws {
79+
let url = URL(string: "https://ua-override.example.com")!
80+
let custom = "my-own-agent/1.2.3"
81+
let client = Client(
82+
url: url,
83+
headers: [.init(name: "user-agent", value: custom)],
84+
component: .helper
85+
)
86+
let sentUA = try await capturedUserAgent(client: client, url: url)
87+
88+
#expect(sentUA == custom)
89+
#expect(!sentUA.contains("coder-desktop"))
90+
}
91+
92+
/// Runs `buildInfo()` against a mock and returns the `User-Agent` it sent.
93+
private func capturedUserAgent(client: Client, url: URL) async throws -> String {
94+
var mock = try Mock(
95+
url: url.appending(path: "api/v2/buildinfo"),
96+
contentType: .json,
97+
statusCode: 200,
98+
data: [.get: CoderSDK.encoder.encode(BuildInfoResponse(version: Self.stubServerVersion))]
99+
)
100+
var sentUA: String?
101+
mock.onRequestHandler = OnRequestHandler { req in
102+
sentUA = req.value(forHTTPHeaderField: Headers.userAgent)
103+
}
104+
mock.register()
105+
106+
_ = try await client.buildInfo()
107+
return try #require(sentUA)
108+
}
109+
}

‎Coder-Desktop/VPNLib/Download.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ public func download(
77
dest: URL,
88
urlSession: URLSession,
99
headers: [HTTPHeader] = [],
10+
component: CoderComponent = .app,
1011
progressUpdates: (@Sendable (DownloadProgress) -> Void)? = nil
1112
) async throws(DownloadError) {
1213
try await DownloadManager().download(
1314
src: src,
1415
dest: dest,
1516
urlSession: urlSession,
1617
headers: headers,
18+
component: component,
1719
progressUpdates: progressUpdates.flatMap { throttle(interval: .milliseconds(10), $0) }
1820
)
1921
}
@@ -58,9 +60,11 @@ private final class DownloadManager: NSObject, @unchecked Sendable {
5860
dest: URL,
5961
urlSession: URLSession,
6062
headers: [HTTPHeader] = [],
63+
component: CoderComponent = .app,
6164
progressUpdates: (@Sendable (DownloadProgress) -> Void)?
6265
) async throws(DownloadError) {
6366
var req = URLRequest(url: src)
67+
req.setCoderUserAgent(component, unlessIn: headers)
6468
for header in headers {
6569
req.addValue(header.value, forHTTPHeaderField: header.name)
6670
}

0 commit comments

Comments
 (0)