diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift index a1dc6bc0..adff1434 100644 --- a/Coder-Desktop/VPN/Manager.swift +++ b/Coder-Desktop/VPN/Manager.swift @@ -6,6 +6,7 @@ import VPNLib actor Manager { let ptp: PacketTunnelProvider let cfg: ManagerConfig + let telemetryEnricher: TelemetryEnricher let tunnelHandle: TunnelHandle let speaker: Speaker @@ -19,6 +20,7 @@ actor Manager { init(with: PacketTunnelProvider, cfg: ManagerConfig) async throws(ManagerError) { ptp = with self.cfg = cfg + telemetryEnricher = TelemetryEnricher() #if arch(arm64) let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-arm64.dylib") #elseif arch(x86_64) @@ -176,6 +178,7 @@ actor Manager { req.value = header.value } } + req = telemetryEnricher.enrich(req) } }) } catch { diff --git a/Coder-Desktop/VPNLib/Speaker.swift b/Coder-Desktop/VPNLib/Speaker.swift index b53f50a8..88e46b05 100644 --- a/Coder-Desktop/VPNLib/Speaker.swift +++ b/Coder-Desktop/VPNLib/Speaker.swift @@ -88,8 +88,11 @@ public actor Speaker Vpn_StartRequest { + var req = original + req.deviceOs = "macOS" + req.deviceID = deviceID + if let version { + req.coderDesktopVersion = version + } + return req + } +} diff --git a/Coder-Desktop/VPNLib/vpn.pb.swift b/Coder-Desktop/VPNLib/vpn.pb.swift index 525f55bb..3e728045 100644 --- a/Coder-Desktop/VPNLib/vpn.pb.swift +++ b/Coder-Desktop/VPNLib/vpn.pb.swift @@ -175,6 +175,118 @@ public struct Vpn_TunnelMessage: Sendable { fileprivate var _rpc: Vpn_RPC? = nil } +/// ClientMessage is a message from the client (to the service). Windows only. +public struct Vpn_ClientMessage: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var rpc: Vpn_RPC { + get {return _rpc ?? Vpn_RPC()} + set {_rpc = newValue} + } + /// Returns true if `rpc` has been explicitly set. + public var hasRpc: Bool {return self._rpc != nil} + /// Clears the value of `rpc`. Subsequent reads from it will return its default value. + public mutating func clearRpc() {self._rpc = nil} + + public var msg: Vpn_ClientMessage.OneOf_Msg? = nil + + public var start: Vpn_StartRequest { + get { + if case .start(let v)? = msg {return v} + return Vpn_StartRequest() + } + set {msg = .start(newValue)} + } + + public var stop: Vpn_StopRequest { + get { + if case .stop(let v)? = msg {return v} + return Vpn_StopRequest() + } + set {msg = .stop(newValue)} + } + + public var status: Vpn_StatusRequest { + get { + if case .status(let v)? = msg {return v} + return Vpn_StatusRequest() + } + set {msg = .status(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public enum OneOf_Msg: Equatable, Sendable { + case start(Vpn_StartRequest) + case stop(Vpn_StopRequest) + case status(Vpn_StatusRequest) + + } + + public init() {} + + fileprivate var _rpc: Vpn_RPC? = nil +} + +/// ServiceMessage is a message from the service (to the client). Windows only. +public struct Vpn_ServiceMessage: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var rpc: Vpn_RPC { + get {return _rpc ?? Vpn_RPC()} + set {_rpc = newValue} + } + /// Returns true if `rpc` has been explicitly set. + public var hasRpc: Bool {return self._rpc != nil} + /// Clears the value of `rpc`. Subsequent reads from it will return its default value. + public mutating func clearRpc() {self._rpc = nil} + + public var msg: Vpn_ServiceMessage.OneOf_Msg? = nil + + public var start: Vpn_StartResponse { + get { + if case .start(let v)? = msg {return v} + return Vpn_StartResponse() + } + set {msg = .start(newValue)} + } + + public var stop: Vpn_StopResponse { + get { + if case .stop(let v)? = msg {return v} + return Vpn_StopResponse() + } + set {msg = .stop(newValue)} + } + + /// either in reply to a StatusRequest or broadcasted + public var status: Vpn_Status { + get { + if case .status(let v)? = msg {return v} + return Vpn_Status() + } + set {msg = .status(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public enum OneOf_Msg: Equatable, Sendable { + case start(Vpn_StartResponse) + case stop(Vpn_StopResponse) + /// either in reply to a StatusRequest or broadcasted + case status(Vpn_Status) + + } + + public init() {} + + fileprivate var _rpc: Vpn_RPC? = nil +} + /// Log is a log message generated by the tunnel. The manager should log it to the system log. It is /// one-way tunnel -> manager with no response. public struct Vpn_Log: Sendable { @@ -599,6 +711,15 @@ public struct Vpn_StartRequest: Sendable { public var headers: [Vpn_StartRequest.Header] = [] + /// Device ID from Coder Desktop + public var deviceID: String = String() + + /// Device OS from Coder Desktop + public var deviceOs: String = String() + + /// Coder Desktop version + public var coderDesktopVersion: String = String() + public var unknownFields = SwiftProtobuf.UnknownStorage() /// Additional HTTP headers added to all requests @@ -661,6 +782,94 @@ public struct Vpn_StopResponse: Sendable { public init() {} } +/// StatusRequest is a request to get the status of the tunnel. The manager +/// replies with a Status. +public struct Vpn_StatusRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +/// Status is sent in response to a StatusRequest or broadcasted to all clients +/// when the status changes. +public struct Vpn_Status: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var lifecycle: Vpn_Status.Lifecycle = .unknown + + public var errorMessage: String = String() + + /// This will be a FULL update with all workspaces and agents, so clients + /// should replace their current peer state. Only the Upserted fields will + /// be populated. + public var peerUpdate: Vpn_PeerUpdate { + get {return _peerUpdate ?? Vpn_PeerUpdate()} + set {_peerUpdate = newValue} + } + /// Returns true if `peerUpdate` has been explicitly set. + public var hasPeerUpdate: Bool {return self._peerUpdate != nil} + /// Clears the value of `peerUpdate`. Subsequent reads from it will return its default value. + public mutating func clearPeerUpdate() {self._peerUpdate = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public enum Lifecycle: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case unknown // = 0 + case starting // = 1 + case started // = 2 + case stopping // = 3 + case stopped // = 4 + case UNRECOGNIZED(Int) + + public init() { + self = .unknown + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .unknown + case 1: self = .starting + case 2: self = .started + case 3: self = .stopping + case 4: self = .stopped + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .unknown: return 0 + case .starting: return 1 + case .started: return 2 + case .stopping: return 3 + case .stopped: return 4 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Vpn_Status.Lifecycle] = [ + .unknown, + .starting, + .started, + .stopping, + .stopped, + ] + + } + + public init() {} + + fileprivate var _peerUpdate: Vpn_PeerUpdate? = nil +} + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "vpn" @@ -945,6 +1154,194 @@ extension Vpn_TunnelMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem } } +extension Vpn_ClientMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ClientMessage" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "rpc"), + 2: .same(proto: "start"), + 3: .same(proto: "stop"), + 4: .same(proto: "status"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._rpc) }() + case 2: try { + var v: Vpn_StartRequest? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .start(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .start(v) + } + }() + case 3: try { + var v: Vpn_StopRequest? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .stop(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .stop(v) + } + }() + case 4: try { + var v: Vpn_StatusRequest? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .status(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .status(v) + } + }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._rpc { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + switch self.msg { + case .start?: try { + guard case .start(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .stop?: try { + guard case .stop(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case .status?: try { + guard case .status(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_ClientMessage, rhs: Vpn_ClientMessage) -> Bool { + if lhs._rpc != rhs._rpc {return false} + if lhs.msg != rhs.msg {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Vpn_ServiceMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ServiceMessage" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "rpc"), + 2: .same(proto: "start"), + 3: .same(proto: "stop"), + 4: .same(proto: "status"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._rpc) }() + case 2: try { + var v: Vpn_StartResponse? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .start(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .start(v) + } + }() + case 3: try { + var v: Vpn_StopResponse? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .stop(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .stop(v) + } + }() + case 4: try { + var v: Vpn_Status? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .status(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .status(v) + } + }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._rpc { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + switch self.msg { + case .start?: try { + guard case .start(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .stop?: try { + guard case .stop(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case .status?: try { + guard case .status(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_ServiceMessage, rhs: Vpn_ServiceMessage) -> Bool { + if lhs._rpc != rhs._rpc {return false} + if lhs.msg != rhs.msg {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Vpn_Log: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Log" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ @@ -1650,6 +2047,9 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme 2: .standard(proto: "coder_url"), 3: .standard(proto: "api_token"), 4: .same(proto: "headers"), + 5: .standard(proto: "device_id"), + 6: .standard(proto: "device_os"), + 7: .standard(proto: "coder_desktop_version"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -1662,6 +2062,9 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme case 2: try { try decoder.decodeSingularStringField(value: &self.coderURL) }() case 3: try { try decoder.decodeSingularStringField(value: &self.apiToken) }() case 4: try { try decoder.decodeRepeatedMessageField(value: &self.headers) }() + case 5: try { try decoder.decodeSingularStringField(value: &self.deviceID) }() + case 6: try { try decoder.decodeSingularStringField(value: &self.deviceOs) }() + case 7: try { try decoder.decodeSingularStringField(value: &self.coderDesktopVersion) }() default: break } } @@ -1680,6 +2083,15 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme if !self.headers.isEmpty { try visitor.visitRepeatedMessageField(value: self.headers, fieldNumber: 4) } + if !self.deviceID.isEmpty { + try visitor.visitSingularStringField(value: self.deviceID, fieldNumber: 5) + } + if !self.deviceOs.isEmpty { + try visitor.visitSingularStringField(value: self.deviceOs, fieldNumber: 6) + } + if !self.coderDesktopVersion.isEmpty { + try visitor.visitSingularStringField(value: self.coderDesktopVersion, fieldNumber: 7) + } try unknownFields.traverse(visitor: &visitor) } @@ -1688,6 +2100,9 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme if lhs.coderURL != rhs.coderURL {return false} if lhs.apiToken != rhs.apiToken {return false} if lhs.headers != rhs.headers {return false} + if lhs.deviceID != rhs.deviceID {return false} + if lhs.deviceOs != rhs.deviceOs {return false} + if lhs.coderDesktopVersion != rhs.coderDesktopVersion {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -1825,3 +2240,80 @@ extension Vpn_StopResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme return true } } + +extension Vpn_StatusRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".StatusRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_StatusRequest, rhs: Vpn_StatusRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Vpn_Status: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Status" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "lifecycle"), + 2: .standard(proto: "error_message"), + 3: .standard(proto: "peer_update"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.lifecycle) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.errorMessage) }() + case 3: try { try decoder.decodeSingularMessageField(value: &self._peerUpdate) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if self.lifecycle != .unknown { + try visitor.visitSingularEnumField(value: self.lifecycle, fieldNumber: 1) + } + if !self.errorMessage.isEmpty { + try visitor.visitSingularStringField(value: self.errorMessage, fieldNumber: 2) + } + try { if let v = self._peerUpdate { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_Status, rhs: Vpn_Status) -> Bool { + if lhs.lifecycle != rhs.lifecycle {return false} + if lhs.errorMessage != rhs.errorMessage {return false} + if lhs._peerUpdate != rhs._peerUpdate {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Vpn_Status.Lifecycle: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "UNKNOWN"), + 1: .same(proto: "STARTING"), + 2: .same(proto: "STARTED"), + 3: .same(proto: "STOPPING"), + 4: .same(proto: "STOPPED"), + ] +} diff --git a/Coder-Desktop/VPNLib/vpn.proto b/Coder-Desktop/VPNLib/vpn.proto index 9d9c2435..b3fe54c5 100644 --- a/Coder-Desktop/VPNLib/vpn.proto +++ b/Coder-Desktop/VPNLib/vpn.proto @@ -44,6 +44,26 @@ message TunnelMessage { } } +// ClientMessage is a message from the client (to the service). Windows only. +message ClientMessage { + RPC rpc = 1; + oneof msg { + StartRequest start = 2; + StopRequest stop = 3; + StatusRequest status = 4; + } +} + +// ServiceMessage is a message from the service (to the client). Windows only. +message ServiceMessage { + RPC rpc = 1; + oneof msg { + StartResponse start = 2; + StopResponse stop = 3; + Status status = 4; // either in reply to a StatusRequest or broadcasted + } +} + // Log is a log message generated by the tunnel. The manager should log it to the system log. It is // one-way tunnel -> manager with no response. message Log { @@ -185,6 +205,12 @@ message StartRequest { string value = 2; } repeated Header headers = 4; + // Device ID from Coder Desktop + string device_id = 5; + // Device OS from Coder Desktop + string device_os = 6; + // Coder Desktop version + string coder_desktop_version = 7; } message StartResponse { @@ -202,3 +228,26 @@ message StopResponse { bool success = 1; string error_message = 2; } + +// StatusRequest is a request to get the status of the tunnel. The manager +// replies with a Status. +message StatusRequest {} + +// Status is sent in response to a StatusRequest or broadcasted to all clients +// when the status changes. +message Status { + enum Lifecycle { + UNKNOWN = 0; + STARTING = 1; + STARTED = 2; + STOPPING = 3; + STOPPED = 4; + } + Lifecycle lifecycle = 1; + string error_message = 2; + + // This will be a FULL update with all workspaces and agents, so clients + // should replace their current peer state. Only the Upserted fields will + // be populated. + PeerUpdate peer_update = 3; +} diff --git a/Coder-Desktop/VPNLibTests/SpeakerTests.swift b/Coder-Desktop/VPNLibTests/SpeakerTests.swift index fd8ffb76..dd837d70 100644 --- a/Coder-Desktop/VPNLibTests/SpeakerTests.swift +++ b/Coder-Desktop/VPNLibTests/SpeakerTests.swift @@ -29,14 +29,15 @@ struct SpeakerTests: Sendable { handshaker = Handshaker( writeFD: pipeMT.fileHandleForWriting, dispatch: dispatch, queue: queue, - role: .manager + role: .manager, + versions: [ProtoVersion(1, 1)] ) } @Test func handshake() async throws { async let v = handshaker.handshake() try await uut.handshake() - #expect(try await v == ProtoVersion(1, 0)) + #expect(try await v == ProtoVersion(1, 1)) } @Test func handleSingleMessage() async throws { diff --git a/Coder-Desktop/VPNLibTests/TelemetryEnricherTests.swift b/Coder-Desktop/VPNLibTests/TelemetryEnricherTests.swift new file mode 100644 index 00000000..becf6b37 --- /dev/null +++ b/Coder-Desktop/VPNLibTests/TelemetryEnricherTests.swift @@ -0,0 +1,25 @@ +import Testing +@testable import VPNLib + +@Suite(.timeLimit(.minutes(1))) +struct TelemetryEnricherTests { + @Test func testEnrichStartRequest() throws { + let enricher0 = TelemetryEnricher() + let original = Vpn_StartRequest.with { req in + req.coderURL = "https://example.com" + req.tunnelFileDescriptor = 123 + } + var enriched = enricher0.enrich(original) + #expect(enriched.coderURL == "https://example.com") + #expect(enriched.tunnelFileDescriptor == 123) + #expect(enriched.deviceOs == "macOS") + #expect(try enriched.coderDesktopVersion.contains(Regex(#"^\d+\.\d+\.\d+$"#))) + let deviceID = enriched.deviceID + #expect(!deviceID.isEmpty) + + // check we get the same deviceID from a new enricher + let enricher1 = TelemetryEnricher() + enriched = enricher1.enrich(original) + #expect(enriched.deviceID == deviceID) + } +}