Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Coder-Desktop/Coder-DesktopHelper/Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ actor Manager {
"Failed to create directories for binary destination (\(dest)): \(error.localizedDescription)"
)
}
let client = Client(url: cfg.serverUrl, headers: cfg.literalHeaders)
let client = Client(url: cfg.serverUrl, headers: cfg.literalHeaders, component: .helper)
let buildInfo: BuildInfoResponse
do {
buildInfo = try await client.buildInfo()
Expand All @@ -68,7 +68,8 @@ actor Manager {
src: binaryPath,
dest: dest,
urlSession: URLSession(configuration: sessionConfig),
headers: cfg.literalHeaders
headers: cfg.literalHeaders,
component: .helper
) { progress in
pushProgress(stage: .downloading, downloadProgress: progress)
}
Expand Down
25 changes: 20 additions & 5 deletions Coder-Desktop/CoderSDK/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ public struct Client: Sendable {
public let url: URL
public var token: String?
public var headers: [HTTPHeader]

public init(url: URL, token: String? = nil, headers: [HTTPHeader] = []) {
public var component: CoderComponent

public init(
url: URL,
token: String? = nil,
headers: [HTTPHeader] = [],
component: CoderComponent = .app
) {
self.url = url
self.token = token
self.headers = headers
self.component = component
}

func request(
Expand All @@ -25,6 +32,7 @@ public struct Client: Sendable {
path: path,
method: method,
headers: headers,
component: component,
body: body
)
}
Expand All @@ -41,7 +49,8 @@ public struct Client: Sendable {
baseURL: url,
path: path,
method: method,
headers: headers
headers: headers,
component: component
)
}
}
Expand Down Expand Up @@ -115,11 +124,13 @@ func doRequest(
path: String,
method: HTTPMethod,
headers: [HTTPHeader] = [],
component: CoderComponent = .app,
body: Data? = nil
) async throws(SDKError) -> HTTPResponse {
let url = baseURL.appendingPathComponent(path)
var req = URLRequest(url: url)
req.httpMethod = method.rawValue
req.setCoderUserAgent(component, unlessIn: headers)
for header in headers {
req.addValue(header.value, forHTTPHeaderField: header.name)
}
Expand All @@ -142,6 +153,7 @@ func request(
path: String,
method: HTTPMethod,
headers: [HTTPHeader] = [],
component: CoderComponent = .app,
body: some Encodable & Sendable
) async throws(SDKError) -> HTTPResponse {
let encodedBody: Data
Expand All @@ -155,6 +167,7 @@ func request(
path: path,
method: method,
headers: headers,
component: component,
body: encodedBody
)
}
Expand All @@ -163,13 +176,15 @@ func request(
baseURL: URL,
path: String,
method: HTTPMethod,
headers: [HTTPHeader] = []
headers: [HTTPHeader] = [],
component: CoderComponent = .app
) async throws(SDKError) -> HTTPResponse {
try await doRequest(
baseURL: baseURL,
path: path,
method: method,
headers: headers
headers: headers,
component: component
)
}

Expand Down
1 change: 1 addition & 0 deletions Coder-Desktop/CoderSDK/HTTP.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ enum HTTPMethod: String, Equatable, Hashable, Sendable {

enum Headers {
static let sessionToken = "Coder-Session-Token"
static let userAgent = "User-Agent"
}
48 changes: 48 additions & 0 deletions Coder-Desktop/CoderSDK/UserAgent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation

/// Identifies which Coder Desktop process is making a request.
public enum CoderComponent: String, Sendable {
case app = "coder-desktop"
case helper = "coder-desktop-core"
}

/// Go's `GOARCH` spelling, so a single pattern matches the CLI, the vpn-daemon and Desktop client.
enum GoArch: String, Sendable {
case arm64
case amd64
}

enum UserAgentDefaults {
static let goos = "darwin"
static let unknownVersion = "0.0.0"

#if arch(arm64)
static let arch: GoArch = .arm64
#elseif arch(x86_64)
static let arch: GoArch = .amd64
#endif

static var version: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? unknownVersion
}
}

/// Formats a `User-Agent` as `<token>/<version> (<goos>/<goarch>)`.
func userAgent(
component: CoderComponent,
version: String = UserAgentDefaults.version,
arch: GoArch = UserAgentDefaults.arch
) -> String {
"\(component.rawValue)/\(version) (\(UserAgentDefaults.goos)/\(arch.rawValue))"
}

public extension URLRequest {
/// Sets the Coder `User-Agent`, unless `headers` already carries one.
mutating func setCoderUserAgent(_ component: CoderComponent, unlessIn headers: [HTTPHeader]) {
let callerSuppliedUA = headers.contains {
$0.name.caseInsensitiveCompare(Headers.userAgent) == .orderedSame
}
guard !callerSuppliedUA else { return }
setValue(userAgent(component: component), forHTTPHeaderField: Headers.userAgent)
}
}
109 changes: 109 additions & 0 deletions Coder-Desktop/CoderSDKTests/UserAgentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
@testable import CoderSDK
import Foundation
import Mocker
import Testing

@Suite(.timeLimit(.minutes(1)))
struct UserAgentTests {
// The pattern operators are expected to match against every Coder client.
static let grammar = #"^coder-desktop(-core)?/[0-9]+\.[0-9]+\.[0-9]+ \(darwin/(amd64|arm64)\)$"#

static let stubVersion = "0.8.4"
static let stubServerVersion = "v2.18.2"

@Test
func appToken() {
#expect(
userAgent(component: .app, version: Self.stubVersion, arch: .arm64)
== "coder-desktop/\(Self.stubVersion) (darwin/arm64)"
)
}

@Test
func helperToken() {
#expect(
userAgent(component: .helper, version: Self.stubVersion, arch: .arm64)
== "coder-desktop-core/\(Self.stubVersion) (darwin/arm64)"
)
}

// The CLI and the vpn-daemon report `amd64`, so an Intel build must too.
@Test
func intelReportsGoArchNotSwiftArch() {
let ua = userAgent(component: .app, version: Self.stubVersion, arch: .amd64)
#expect(ua == "coder-desktop/\(Self.stubVersion) (darwin/amd64)")
#expect(!ua.contains("x86_64"))
}

@Test
func matchesGrammar() throws {
let regex = try Regex(Self.grammar)
for component in [CoderComponent.app, .helper] {
for arch in [GoArch.arm64, .amd64] {
let ua = userAgent(component: component, version: Self.stubVersion, arch: arch)
#expect(ua.contains(regex))
}
// The version the running bundle actually reports.
#expect(userAgent(component: component).contains(regex))
}
}

@Test
func missingVersionFallsBackToValidGrammar() throws {
let regex = try Regex(Self.grammar)
let ua = userAgent(
component: .app,
version: UserAgentDefaults.unknownVersion,
arch: .arm64
)
#expect(ua == "coder-desktop/\(UserAgentDefaults.unknownVersion) (darwin/arm64)")
#expect(ua.contains(regex))
}

@Test
func setOnRequest() async throws {
// A distinct host per test: Mocker's registry is process-global and
// suites run in parallel.
let url = URL(string: "https://ua-default.example.com")!
let client = Client(url: url, component: .helper)
let sentUA = try await capturedUserAgent(client: client, url: url)

#expect(try sentUA.contains(Regex(Self.grammar)))
#expect(sentUA.hasPrefix("coder-desktop-core/"))
}

// A user-configured literal header must replace ours outright. Caller headers
// are applied with `addValue`, which would otherwise comma-join the two.
@Test
func callerSuppliedUserAgentWins() async throws {
let url = URL(string: "https://ua-override.example.com")!
let custom = "my-own-agent/1.2.3"
let client = Client(
url: url,
headers: [.init(name: "user-agent", value: custom)],
component: .helper
)
let sentUA = try await capturedUserAgent(client: client, url: url)

#expect(sentUA == custom)
#expect(!sentUA.contains("coder-desktop"))
}

/// Runs `buildInfo()` against a mock and returns the `User-Agent` it sent.
private func capturedUserAgent(client: Client, url: URL) async throws -> String {
var mock = try Mock(
url: url.appending(path: "api/v2/buildinfo"),
contentType: .json,
statusCode: 200,
data: [.get: CoderSDK.encoder.encode(BuildInfoResponse(version: Self.stubServerVersion))]
)
var sentUA: String?
mock.onRequestHandler = OnRequestHandler { req in
sentUA = req.value(forHTTPHeaderField: Headers.userAgent)
}
mock.register()

_ = try await client.buildInfo()
return try #require(sentUA)
}
}
4 changes: 4 additions & 0 deletions Coder-Desktop/VPNLib/Download.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ public func download(
dest: URL,
urlSession: URLSession,
headers: [HTTPHeader] = [],
component: CoderComponent = .app,
progressUpdates: (@Sendable (DownloadProgress) -> Void)? = nil
) async throws(DownloadError) {
try await DownloadManager().download(
src: src,
dest: dest,
urlSession: urlSession,
headers: headers,
component: component,
progressUpdates: progressUpdates.flatMap { throttle(interval: .milliseconds(10), $0) }
)
}
Expand Down Expand Up @@ -58,9 +60,11 @@ private final class DownloadManager: NSObject, @unchecked Sendable {
dest: URL,
urlSession: URLSession,
headers: [HTTPHeader] = [],
component: CoderComponent = .app,
progressUpdates: (@Sendable (DownloadProgress) -> Void)?
) async throws(DownloadError) {
var req = URLRequest(url: src)
req.setCoderUserAgent(component, unlessIn: headers)
for header in headers {
req.addValue(header.value, forHTTPHeaderField: header.name)
}
Expand Down