Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ddaaa86
Require matching email for iOS pairing
lawrencecchen Jun 13, 2026
0050317
Merge remote-tracking branch 'origin/main' into feat-ios-pairing-emai…
lawrencecchen Jun 13, 2026
57b8589
Address iOS pairing review findings
lawrencecchen Jun 13, 2026
b5b1fa3
Fix pairing version warning lifecycle
lawrencecchen Jun 13, 2026
de56d8e
Keep pairing warning sheet reachable
lawrencecchen Jun 13, 2026
0baf8a7
Keep QR version warning preflight non-destructive
lawrencecchen Jun 13, 2026
64e861e
Supersede QR warnings without opening pairing early
lawrencecchen Jun 13, 2026
2339bd2
Split pairing attempt generation from live connection
lawrencecchen Jun 13, 2026
a42dc7d
Defer attach URL until auth restore completes
lawrencecchen Jun 13, 2026
5cb9b73
Allow attach auth before email is available
lawrencecchen Jun 13, 2026
5995237
Record QR validation pairing analytics
lawrencecchen Jun 13, 2026
bbacd8c
Address mobile pairing review findings
lawrencecchen Jun 13, 2026
bb13811
Protect pairing QR email and analytics
lawrencecchen Jun 13, 2026
b363f86
Use opaque account binding in pairing QR
lawrencecchen Jun 13, 2026
74632bb
Use pairing compatibility for mobile warning
lawrencecchen Jun 13, 2026
c313540
Align compatibility warning title fallback
lawrencecchen Jun 13, 2026
becf69f
Restore mobile notification sync topics
lawrencecchen Jun 13, 2026
b3e3019
Warn for legacy pairing QR compatibility
lawrencecchen Jun 13, 2026
26b87b5
Restore mobile notification dismiss sync
lawrencecchen Jun 13, 2026
4be32eb
Restore Mac mobile notification sync handlers
lawrencecchen Jun 13, 2026
0452e49
Restore iOS notification dismiss push hooks
lawrencecchen Jun 13, 2026
d759c83
Satisfy notification sync policy checks
lawrencecchen Jun 13, 2026
f7043f1
Keep email out of compact pairing payload
lawrencecchen Jun 13, 2026
38f745e
Satisfy pairing payload policy checks
lawrencecchen Jun 13, 2026
e5beff0
Merge origin/main into pairing warning branch
lawrencecchen Jun 13, 2026
7518ab7
Satisfy mobile package convention lint
lawrencecchen Jun 13, 2026
6cb1647
Refresh Swift file length budget for pairing flow
lawrencecchen Jun 13, 2026
a74a9fc
Guard pairing warning acceptance taps
lawrencecchen Jun 14, 2026
4a5dae3
Merge origin/main into pairing warning branch
lawrencecchen Jun 14, 2026
af3c18b
Warn on compact pairing codes without compatibility
lawrencecchen Jun 14, 2026
7142689
Warn on legacy pairing codes without compatibility
lawrencecchen Jun 14, 2026
3e506ec
Surface pairing warnings from host picker
lawrencecchen Jun 14, 2026
173742f
Merge latest origin/main into pairing warning branch
lawrencecchen Jun 14, 2026
005cedc
Dismiss host picker after warning retry failures
lawrencecchen Jun 14, 2026
454c29f
Restore pairing validation analytics
lawrencecchen Jun 14, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import Foundation
/// The compact grammar keeps the same envelope but encodes only what pairing
/// actually consumes: short keys, no empty optional fields, no auth token, no
/// display name (read post-handshake from `mobile.host.status`), and no
/// expiry. A pairing QR never expires; the owner's Stack access token is the
/// expiry. It does keep non-secret pairing context: the Mac account email,
/// shared pairing compatibility level, and app version/build, so the phone can
/// fail fast on an account mismatch and warn before continuing across
/// compatibility skew. A pairing QR never expires;
/// the owner's Stack access token is the
/// host's sole authorization gate (`MobileHostService.authorizationError(for:)`),
/// so ticket age authorizes nothing.
///
Expand All @@ -25,7 +29,8 @@ import Foundation
/// shows a pairing error instead of silently misreading the ticket.
///
/// Key map (ticket): `v` version, `w` workspaceID (omitted when empty),
/// `t` terminalID, `d` macDeviceID, `r` routes.
/// `t` terminalID, `d` macDeviceID, `u` Mac account email, `pc` pairing
/// compatibility version, `av` app version, `ab` app build, `r` routes.
/// Key map (route): `i` id (omitted when the decoder can resynthesize it:
/// `kind` for the first route of a kind, `kind_N` for the Nth), `k` kind raw
/// value, `p` priority (omitted when 0), `e` endpoint.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import Foundation

/// The minimal pairing-QR grammar: plain `host:port` routes in the URL query,
/// nothing else.
/// The minimal pairing-QR grammar: expected Mac account/build metadata plus
/// plain `host:port` routes in the URL query.
///
/// `cmux-ios://attach?v=2&r=<host>:<port>[&r=<host>:<port>...]`
/// `cmux-ios://attach?v=2&ub=<stack-user-id>&pc=<compat>&av=<version>&ab=<build>&r=<host>:<port>[&r=<host>:<port>...]`
///
/// A pairing QR needs to tell the phone exactly one thing: where to dial.
/// Everything else the earlier grammars carried has a better channel or no
/// reason to exist:
/// A pairing QR needs to tell the phone where to dial and which non-secret
/// account/build context to check before dialing. The account value is the
/// opaque Stack user id, never the email itself. Everything else the earlier
/// grammars carried has a better channel or no reason to exist:
/// - **No auth token.** The owner's Stack access token is the host's sole
/// authorization gate; a token in the QR authorized nothing and made the
/// code look like a leaked credential.
Expand Down Expand Up @@ -62,14 +63,28 @@ public struct CmxPairingQRCode: Sendable {
guard let routes = encodableRoutes(of: ticket) else {
return nil
}
var items: [String] = ["v=\(Self.version)"]
if let userID = normalizedNonEmpty(ticket.macUserID) {
items.append("ub=\(percentEncodeQueryValue(userID))")
}
if let compatibilityVersion = ticket.macPairingCompatibilityVersion {
items.append("pc=\(compatibilityVersion)")
}
if let version = normalizedNonEmpty(ticket.macAppVersion) {
items.append("av=\(percentEncodeQueryValue(version))")
}
if let build = normalizedNonEmpty(ticket.macAppBuild) {
items.append("ab=\(percentEncodeQueryValue(build))")
}
let routeItems = routes.map { route -> String in
guard case let .hostPort(host, port) = route.endpoint else {
// Unreachable: `encodableRoutes` admits host/port endpoints only.
return ""
}
return "r=\(hostPortString(host: host, port: port))"
}
return "cmux-ios://attach?v=\(Self.version)&" + routeItems.joined(separator: "&")
items.append(contentsOf: routeItems)
return "cmux-ios://attach?" + items.joined(separator: "&")
}

/// Whether `ticket` is expressible in the minimal grammar; see
Expand Down Expand Up @@ -171,6 +186,11 @@ public struct CmxPairingQRCode: Sendable {
terminalID: nil,
macDeviceID: "",
macDisplayName: nil,
macUserEmail: queryValue(named: "e", in: components),
macUserID: queryValue(named: "ub", in: components),
macPairingCompatibilityVersion: queryInt(named: "pc", in: components),
macAppVersion: queryValue(named: "av", in: components),
macAppBuild: queryValue(named: "ab", in: components),
routes: routes,
expiresAt: nil,
authToken: nil
Expand Down Expand Up @@ -243,4 +263,24 @@ private extension CmxPairingQRCode {
|| byte == UInt8(ascii: ":")
}
}

func queryValue(named name: String, in components: URLComponents) -> String? {
normalizedNonEmpty(components.queryItems?.first(where: { $0.name == name })?.value)
}

func queryInt(named name: String, in components: URLComponents) -> Int? {
guard let value = queryValue(named: name, in: components) else { return nil }
return Int(value)
}

func normalizedNonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil
}

func percentEncodeQueryValue(_ value: String) -> String {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "&=+")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
Comment on lines +281 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider explicit handling when percent-encoding fails.

The fallback ?? value returns the original unencoded value if addingPercentEncoding fails. For email addresses with + (e.g., user+tag@example.com) or versions with special characters, this could inject reserved characters into the URL query, potentially breaking parsing.

While encoding failure is rare for typical email/version strings, explicit handling would be more defensive.

🛡️ Safer fallback options

Option 1: Return empty string and skip the parameter:

 func percentEncodeQueryValue(_ value: String) -> String {
     var allowed = CharacterSet.urlQueryAllowed
     allowed.remove(charactersIn: "&=+")
-    return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
+    return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
 }

Then filter empty results:

 if let email = normalizedNonEmpty(ticket.macUserEmail) {
-    items.append("e=\(percentEncodeQueryValue(email))")
+    let encoded = percentEncodeQueryValue(email)
+    if !encoded.isEmpty {
+        items.append("e=\(encoded)")
+    }
 }

Option 2: Use a safe placeholder:

-    return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
+    return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? "ENCODING_FAILED"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func percentEncodeQueryValue(_ value: String) -> String {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "&=+")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
func percentEncodeQueryValue(_ value: String) -> String {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "&=+")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? "ENCODING_FAILED"
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift` around
lines 270 - 274, The percentEncodeQueryValue(_:) currently falls back to
returning the raw unencoded value on failure which can inject reserved
characters into query strings; change it to return a safe fallback (e.g., empty
string or a fixed placeholder like "__ENCODING_FAILED__") instead of the raw
value, and adjust callers that build the query (where percentEncodeQueryValue is
used) to filter out or skip parameters whose encoded value is empty or the
placeholder so malformed values are not inserted into the URL; update references
to percentEncodeQueryValue and the query-building logic to implement this
defensive behavior.

}
35 changes: 35 additions & 0 deletions Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable {
case terminalID
case macDeviceID
case macDisplayName
case macUserEmail
case macUserID
case macPairingCompatibilityVersion
case macAppVersion
case macAppBuild
case routes
case expiresAt
case authToken = "auth_token"
Expand All @@ -195,6 +200,18 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable {
public let terminalID: String?
public let macDeviceID: String
public let macDisplayName: String?
/// The signed-in Mac account email the phone must match before pairing.
public let macUserEmail: String?
/// The opaque Stack user id for the Mac account. Public pairing QR codes
/// carry this instead of an email so the phone can reject the wrong
/// signed-in account without exposing an enumerable email address.
public let macUserID: String?
/// Shared mobile pairing compatibility level reported by the Mac.
public let macPairingCompatibilityVersion: Int?
/// The Mac app's marketing version, displayed with compatibility warnings.
public let macAppVersion: String?
/// The Mac app's build number, displayed with version mismatch warnings when present.
public let macAppBuild: String?
public let routes: [CmxAttachRoute]
/// When the ticket's attach token stops being usable, or `nil` for tickets
/// that never expire (the pairing QR carries no token and no expiry; Stack
Expand All @@ -212,6 +229,14 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable {
terminalID: container.decodeIfPresent(String.self, forKey: .terminalID),
macDeviceID: container.decode(String.self, forKey: .macDeviceID),
macDisplayName: container.decodeIfPresent(String.self, forKey: .macDisplayName),
macUserEmail: container.decodeIfPresent(String.self, forKey: .macUserEmail),
macUserID: container.decodeIfPresent(String.self, forKey: .macUserID),
macPairingCompatibilityVersion: container.decodeIfPresent(
Int.self,
forKey: .macPairingCompatibilityVersion
),
macAppVersion: container.decodeIfPresent(String.self, forKey: .macAppVersion),
macAppBuild: container.decodeIfPresent(String.self, forKey: .macAppBuild),
routes: container.decode([CmxAttachRoute].self, forKey: .routes),
expiresAt: container.decodeIfPresent(Date.self, forKey: .expiresAt),
authToken: try Self.decodeAuthToken(from: decoder)
Expand Down Expand Up @@ -239,6 +264,11 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable {
terminalID: String?,
macDeviceID: String,
macDisplayName: String?,
macUserEmail: String? = nil,
macUserID: String? = nil,
macPairingCompatibilityVersion: Int? = nil,
macAppVersion: String? = nil,
macAppBuild: String? = nil,
routes: [CmxAttachRoute],
expiresAt: Date? = nil,
authToken: String? = nil
Expand All @@ -248,6 +278,11 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable {
self.terminalID = terminalID
self.macDeviceID = macDeviceID
self.macDisplayName = macDisplayName
self.macUserEmail = macUserEmail
self.macUserID = macUserID
self.macPairingCompatibilityVersion = macPairingCompatibilityVersion
self.macAppVersion = macAppVersion
self.macAppBuild = macAppBuild
self.routes = routes
self.expiresAt = expiresAt
self.authToken = authToken
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,21 @@ struct CompactAttachTicket: Codable {
let w: String?
let t: String?
let d: String
let u: String?
let pc: Int?
let av: String?
let ab: String?
let r: [CompactAttachRoute]

init(_ ticket: CmxAttachTicket) {
v = ticket.version
w = Self.normalizedNonEmpty(ticket.workspaceID)
t = Self.normalizedNonEmpty(ticket.terminalID)
d = ticket.macDeviceID
u = Self.normalizedNonEmpty(ticket.macUserEmail)
pc = ticket.macPairingCompatibilityVersion
av = Self.normalizedNonEmpty(ticket.macAppVersion)
ab = Self.normalizedNonEmpty(ticket.macAppBuild)
r = Self.compactedRoutes(ticket.routes)
}
Comment on lines 21 to 31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalization inconsistency across metadata fields.

CompactAttachTicket.normalizedNonEmpty (line 46) checks !value.isEmpty without trimming whitespace, while CmxPairingQRCode.normalizedNonEmpty (CmxPairingQRCode.swift:265) and MobileHostBuildIdentity.normalized (MobileHostBuildIdentity.swift:14) trim whitespace before checking. This means:

  • A whitespace-only macUserEmail like " " would be omitted from QR URLs (normalized to nil after trim) but included in compact payloads (kept as-is).
  • The same ticket could encode differently depending on which grammar is used.

Since these values come from controlled sources (authenticated email, Bundle info), the practical risk is low, but consistency would prevent subtle edge-case bugs.

🔧 Proposed fix to align normalization

Update normalizedNonEmpty to trim whitespace like the other normalizers:

 private static func normalizedNonEmpty(_ value: String?) -> String? {
-    guard let value, !value.isEmpty else {
-        return nil
-    }
-    return value
+    let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
+    return trimmed?.isEmpty == false ? trimmed : nil
 }

Note: This also affects workspaceID and terminalID (lines 22-23), so verify that trimming is appropriate for those fields as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift`
around lines 20 - 29, The CompactAttachTicket.normalizedNonEmpty function
currently checks !value.isEmpty without trimming, causing whitespace-only
strings to be kept; update normalizedNonEmpty to trim whitespace (e.g., call
.trimmingCharacters(in: .whitespacesAndNewlines)) and return nil if the trimmed
result is empty so its behavior matches CmxPairingQRCode.normalizedNonEmpty and
MobileHostBuildIdentity.normalized; ensure this change applies to all callers in
CompactAttachTicket.init (workspaceID, terminalID, macUserEmail, macAppVersion,
macAppBuild) and verify trimming is acceptable for those fields.


Expand All @@ -29,6 +37,10 @@ struct CompactAttachTicket: Codable {
terminalID: t,
macDeviceID: d,
macDisplayName: nil,
macUserEmail: u,
macPairingCompatibilityVersion: pc,
macAppVersion: av,
macAppBuild: ab,
routes: Self.expandedRoutes(r),
expiresAt: nil
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ public struct CmxMobileDefaults {

/// The default daemon host port mobile clients dial when none is supplied.
public static let defaultHostPort = 58_465
/// Shared Mac/iOS pairing compatibility level. Bump this only when current
/// clients can pair but may behave incorrectly without explicit user approval.
public static let pairingCompatibilityVersion = 1
}

public enum CmxAttachTransportKind: String, Codable, Sendable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ private func legacyDecoder() -> JSONDecoder {
terminalID: "terminal-9",
macDeviceID: "mac-1",
macDisplayName: "Studio",
macUserEmail: "user@example.com",
macPairingCompatibilityVersion: 1,
macAppVersion: "0.64.15",
macAppBuild: "42",
routes: [try hostPortRoute(priority: 1)],
expiresAt: wholeSecondFutureExpiry(),
authToken: "ticket-secret"
Expand All @@ -57,6 +61,10 @@ private func legacyDecoder() -> JSONDecoder {
#expect(json.contains("\"v\":1"))
#expect(json.contains("\"w\":\"workspace-1\""))
#expect(json.contains("\"d\":\"mac-1\""))
#expect(json.contains("\"u\":\"user@example.com\""))
#expect(json.contains("\"pc\":1"))
#expect(json.contains("\"av\":\"0.64.15\""))
#expect(json.contains("\"ab\":\"42\""))
// The grammar no longer carries the display name or an expiry: the name
// arrives post-handshake via `mobile.host.status`, and a pairing QR never
// expires.
Expand Down Expand Up @@ -94,6 +102,10 @@ private func legacyDecoder() -> JSONDecoder {
terminalID: "terminal-9",
macDeviceID: "mac-1",
macDisplayName: "Studio",
macUserEmail: "user@example.com",
macPairingCompatibilityVersion: 1,
macAppVersion: "0.64.15",
macAppBuild: "42",
routes: routes,
expiresAt: wholeSecondFutureExpiry(),
authToken: "ticket-secret"
Expand All @@ -107,6 +119,10 @@ private func legacyDecoder() -> JSONDecoder {
#expect(decoded.workspaceID == ticket.workspaceID)
#expect(decoded.terminalID == ticket.terminalID)
#expect(decoded.macDeviceID == ticket.macDeviceID)
#expect(decoded.macUserEmail == ticket.macUserEmail)
#expect(decoded.macPairingCompatibilityVersion == ticket.macPairingCompatibilityVersion)
#expect(decoded.macAppVersion == ticket.macAppVersion)
#expect(decoded.macAppBuild == ticket.macAppBuild)
// Routes round-trip losslessly even with custom ids ("ws" differs from
// the synthesized "websocket", so it is carried verbatim).
#expect(decoded.routes == ticket.routes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,41 @@ import Testing
#expect(decoded.routes.map(\.priority) == [10, 20])
}

@Test func roundTripsUserIDAndBuildMetadataWithoutExposingEmail() throws {
let ticket = try CmxAttachTicket(
workspaceID: "",
terminalID: nil,
macDeviceID: "mac-device-uuid",
macDisplayName: "Lawrence's Mac",
macUserEmail: "Lawrence@Example.com",
macUserID: "user_mac_123",
macPairingCompatibilityVersion: 1,
macAppVersion: "0.64.15",
macAppBuild: "42",
routes: [
try tailscaleRoute(index: 0, host: "100.64.0.5"),
],
expiresAt: Date().addingTimeInterval(600),
authToken: "minted-but-never-in-the-qr"
)

let url = try #require(CmxPairingQRCode().encode(ticket))
#expect(url.contains("ub=user_mac_123"))
#expect(!url.contains("Lawrence@Example.com"))
#expect(!url.lowercased().contains("lawrence@example.com"))
#expect(url.contains("pc=1"))
#expect(url.contains("av=0.64.15"))
#expect(url.contains("ab=42"))

let decoded = try CmxPairingQRCode().decode(try components(url))
#expect(decoded.macUserEmail == nil)
#expect(decoded.macUserID == "user_mac_123")
#expect(decoded.macPairingCompatibilityVersion == 1)
#expect(decoded.macAppVersion == "0.64.15")
#expect(decoded.macAppBuild == "42")
#expect(decoded.routes == ticket.routes)
}

@Test func roundTripsIPv6LiteralThroughRealURLParsing() throws {
let route = try tailscaleRoute(index: 0, host: "fd7a:115c:a1e0::1")
let ticket = try pairingTicket(routes: [route])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
/// which paired-Mac record the connection belongs to (reconnect-on-launch
/// and the host switcher key on it). `nil` from older Macs.
public let macDeviceID: String?
/// The Mac app's marketing version, for warning-only compatibility checks.
public let macAppVersion: String?
/// The Mac app's build number, for warning display.
public let macAppBuild: String?

private enum CodingKeys: String, CodingKey {
case capabilities
case terminalFidelity = "terminal_fidelity"
case macDisplayName = "mac_display_name"
case macDeviceID = "mac_device_id"
case macAppVersion = "mac_app_version"
case macAppBuild = "mac_app_build"
}

public init(from decoder: any Decoder) throws {
Expand All @@ -35,6 +41,8 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
terminalFidelity = try container.decodeIfPresent(String.self, forKey: .terminalFidelity)
macDisplayName = try container.decodeIfPresent(String.self, forKey: .macDisplayName)
macDeviceID = try container.decodeIfPresent(String.self, forKey: .macDeviceID)
macAppVersion = try container.decodeIfPresent(String.self, forKey: .macAppVersion)
macAppBuild = try container.decodeIfPresent(String.self, forKey: .macAppBuild)
}

/// Decode a host-status response from raw JSON data.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import Testing
terminalID: nil,
macDeviceID: "mac-1",
macDisplayName: "Studio",
macUserEmail: "user@example.com",
macPairingCompatibilityVersion: 1,
macAppVersion: "0.64.15",
macAppBuild: "42",
routes: [
try CmxAttachRoute(
id: "tailscale",
Expand Down Expand Up @@ -41,6 +45,10 @@ import Testing

let decoded = try CmxAttachTicketInput.decode(url)
#expect(decoded.macDeviceID == "mac-1")
#expect(decoded.macUserEmail == "user@example.com")
#expect(decoded.macPairingCompatibilityVersion == 1)
#expect(decoded.macAppVersion == "0.64.15")
#expect(decoded.macAppBuild == "42")
#expect(decoded.workspaceID == "")
#expect(decoded.routes == ticket.routes)
// The compact QR grammar intentionally drops the auth token (it
Expand Down Expand Up @@ -122,13 +130,18 @@ import Testing
// payload blob) routes through the same input decoder as everything
// else the scanner or a deep link can hand us.
let decoded = try CmxAttachTicketInput.decode(
"cmux-ios://attach?v=2&r=lawrences-mac.tail1234.ts.net:58465&r=100.64.0.5:58465"
"cmux-ios://attach?v=2&ub=user_mac_123&pc=1&av=0.64.15&ab=42&r=lawrences-mac.tail1234.ts.net:58465&r=100.64.0.5:58465"
)
#expect(decoded.workspaceID == "")
#expect(decoded.macDeviceID == "")
#expect(decoded.macDisplayName == nil)
#expect(decoded.expiresAt == nil)
#expect(decoded.authToken == nil)
#expect(decoded.macUserEmail == nil)
#expect(decoded.macUserID == "user_mac_123")
#expect(decoded.macPairingCompatibilityVersion == 1)
#expect(decoded.macAppVersion == "0.64.15")
#expect(decoded.macAppBuild == "42")
#expect(decoded.routes.count == 2)
#expect(decoded.routes.map(\.id) == ["tailscale", "tailscale_2"])
#expect(decoded.routes.allSatisfy { $0.kind == .tailscale })
Expand Down
Loading
Loading