From ddaaa864e5d3f1782225f6bff38733674adc80c9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:59:59 -0700 Subject: [PATCH 01/31] Require matching email for iOS pairing --- .../CmxAttachTicketCompactCoder.swift | 8 +- .../CMUXMobileCore/CmxPairingQRCode.swift | 43 ++++++-- .../Sources/CMUXMobileCore/CmxTransport.swift | 18 ++++ .../CMUXMobileCore/CompactAttachTicket.swift | 9 ++ .../CmxAttachTicketCompactCoderTests.swift | 12 +++ .../CmxPairingQRCodeTests.swift | 28 ++++++ .../MobileHostStatusResponse.swift | 8 ++ .../CmxAttachTicketInputTests.swift | 11 ++- .../MobilePairingFailure.swift | 25 ++++- .../MobileShellComposite.swift | 97 +++++++++++++++++++ .../MobilePairingFailureTests.swift | 12 +++ .../MobilePairingURLConnectionResult.swift | 2 + .../CMUXMobileRootView.swift | 4 + .../CmuxMobileShellUI/PairingView.swift | 30 ++++++ .../MobileRootAuthGate.swift | 2 + .../MobileRootAuthGateTests.swift | 5 + Sources/Mobile/MobileAttachTicketStore.swift | 6 ++ Sources/Mobile/MobileHostBuildIdentity.swift | 18 ++++ Sources/Mobile/MobileHostService.swift | 62 +++++++----- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/MobileHostAuthorizationTests.swift | 22 ++--- ios/cmux/Resources/Localizable.xcstrings | 89 ++++++++++++++++- .../cmuxFeatureTests/cmuxFeatureTests.swift | 91 +++++++++++++++++ 23 files changed, 557 insertions(+), 49 deletions(-) create mode 100644 Sources/Mobile/MobileHostBuildIdentity.swift diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift index 3d053eb5425..61d8504b3e5 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift @@ -9,7 +9,10 @@ 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 and +/// app version/build, so the phone can fail fast on an account mismatch and +/// warn before continuing across version 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. /// @@ -25,7 +28,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, `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. diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift index 403ba9069e8..6eaa14e61c4 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift @@ -1,13 +1,13 @@ import Foundation -/// The minimal pairing-QR grammar: plain `host:port` routes in the URL query, -/// nothing else. +/// The minimal pairing-QR grammar: expected Mac email/build metadata plus plain +/// `host:port` routes in the URL query. /// -/// `cmux-ios://attach?v=2&r=:[&r=:...]` +/// `cmux-ios://attach?v=2&e=&av=&ab=&r=:[&r=:...]` /// -/// 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. 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. @@ -62,6 +62,16 @@ public struct CmxPairingQRCode: Sendable { guard let routes = encodableRoutes(of: ticket) else { return nil } + var items: [String] = ["v=\(Self.version)"] + if let email = normalizedNonEmpty(ticket.macUserEmail) { + items.append("e=\(percentEncodeQueryValue(email))") + } + 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. @@ -69,7 +79,8 @@ public struct CmxPairingQRCode: Sendable { } 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 @@ -171,6 +182,9 @@ public struct CmxPairingQRCode: Sendable { terminalID: nil, macDeviceID: "", macDisplayName: nil, + macUserEmail: queryValue(named: "e", in: components), + macAppVersion: queryValue(named: "av", in: components), + macAppBuild: queryValue(named: "ab", in: components), routes: routes, expiresAt: nil, authToken: nil @@ -243,4 +257,19 @@ 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 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 + } } diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift index 62f9f03b556..162c158d3da 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift @@ -175,6 +175,9 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { case terminalID case macDeviceID case macDisplayName + case macUserEmail + case macAppVersion + case macAppBuild case routes case expiresAt case authToken = "auth_token" @@ -195,6 +198,12 @@ 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 Mac app's marketing version, used for warning-only compatibility checks. + 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 @@ -212,6 +221,9 @@ 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), + 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) @@ -239,6 +251,9 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { terminalID: String?, macDeviceID: String, macDisplayName: String?, + macUserEmail: String? = nil, + macAppVersion: String? = nil, + macAppBuild: String? = nil, routes: [CmxAttachRoute], expiresAt: Date? = nil, authToken: String? = nil @@ -248,6 +263,9 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { self.terminalID = terminalID self.macDeviceID = macDeviceID self.macDisplayName = macDisplayName + self.macUserEmail = macUserEmail + self.macAppVersion = macAppVersion + self.macAppBuild = macAppBuild self.routes = routes self.expiresAt = expiresAt self.authToken = authToken diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift index 7b4bf498b23..94c69f2e440 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift @@ -12,6 +12,9 @@ struct CompactAttachTicket: Codable { let w: String? let t: String? let d: String + let u: String? + let av: String? + let ab: String? let r: [CompactAttachRoute] init(_ ticket: CmxAttachTicket) { @@ -19,6 +22,9 @@ struct CompactAttachTicket: Codable { w = Self.normalizedNonEmpty(ticket.workspaceID) t = Self.normalizedNonEmpty(ticket.terminalID) d = ticket.macDeviceID + u = Self.normalizedNonEmpty(ticket.macUserEmail) + av = Self.normalizedNonEmpty(ticket.macAppVersion) + ab = Self.normalizedNonEmpty(ticket.macAppBuild) r = Self.compactedRoutes(ticket.routes) } @@ -29,6 +35,9 @@ struct CompactAttachTicket: Codable { terminalID: t, macDeviceID: d, macDisplayName: nil, + macUserEmail: u, + macAppVersion: av, + macAppBuild: ab, routes: Self.expandedRoutes(r), expiresAt: nil ) diff --git a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift index e01f9d1c920..e2adb9576cf 100644 --- a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift +++ b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift @@ -41,6 +41,9 @@ private func legacyDecoder() -> JSONDecoder { terminalID: "terminal-9", macDeviceID: "mac-1", macDisplayName: "Studio", + macUserEmail: "user@example.com", + macAppVersion: "0.64.15", + macAppBuild: "42", routes: [try hostPortRoute(priority: 1)], expiresAt: wholeSecondFutureExpiry(), authToken: "ticket-secret" @@ -57,6 +60,9 @@ 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("\"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. @@ -94,6 +100,9 @@ private func legacyDecoder() -> JSONDecoder { terminalID: "terminal-9", macDeviceID: "mac-1", macDisplayName: "Studio", + macUserEmail: "user@example.com", + macAppVersion: "0.64.15", + macAppBuild: "42", routes: routes, expiresAt: wholeSecondFutureExpiry(), authToken: "ticket-secret" @@ -107,6 +116,9 @@ 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.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) diff --git a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift index ae34337d987..aa24d6ecd10 100644 --- a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift +++ b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift @@ -72,6 +72,34 @@ import Testing #expect(decoded.routes.map(\.priority) == [10, 20]) } + @Test func roundTripsEmailAndBuildMetadata() throws { + let ticket = try CmxAttachTicket( + workspaceID: "", + terminalID: nil, + macDeviceID: "mac-device-uuid", + macDisplayName: "Lawrence's Mac", + macUserEmail: "Lawrence@Example.com", + 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("e=Lawrence@Example.com")) + #expect(url.contains("av=0.64.15")) + #expect(url.contains("ab=42")) + + let decoded = try CmxPairingQRCode().decode(try components(url)) + #expect(decoded.macUserEmail == "Lawrence@Example.com") + #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]) diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostStatusResponse.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostStatusResponse.swift index 5803724b41f..115eed88866 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostStatusResponse.swift +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostStatusResponse.swift @@ -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 { @@ -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. diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift index b918ad298a9..814693f0786 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift @@ -14,6 +14,9 @@ import Testing terminalID: nil, macDeviceID: "mac-1", macDisplayName: "Studio", + macUserEmail: "user@example.com", + macAppVersion: "0.64.15", + macAppBuild: "42", routes: [ try CmxAttachRoute( id: "tailscale", @@ -41,6 +44,9 @@ import Testing let decoded = try CmxAttachTicketInput.decode(url) #expect(decoded.macDeviceID == "mac-1") + #expect(decoded.macUserEmail == "user@example.com") + #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 @@ -122,13 +128,16 @@ 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&e=user@example.com&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 == "user@example.com") + #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 }) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index 334df5cf937..d9c51354b40 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -43,6 +43,8 @@ public enum MobilePairingFailureCategory: Equatable, Sendable { case connectionDropped(host: String?, port: Int?) /// The Mac is signed in to a different cmux account than this device. case accountMismatch + /// The pairing code was minted for a different email than this device. + case emailMismatch(expected: String, actual: String?) /// The owner's account could not be verified with the Mac (stale/invalid /// token, or a release-vs-development build mismatch). case authFailed @@ -79,6 +81,7 @@ extension MobilePairingFailureCategory { case .handshakeTimedOut: return "timeout" case .connectionDropped: return "connection_dropped" case .accountMismatch: return "account_mismatch" + case .emailMismatch: return "email_mismatch" case .authFailed: return "auth" case .ticketExpired: return "ticket_expired" case .invalidCode: return "invalid_code" @@ -94,7 +97,7 @@ extension MobilePairingFailureCategory { /// (Sign Out) instead of a "could not connect / Retry" banner. public var isAuthorizationFailure: Bool { switch self { - case .accountMismatch, .authFailed, .ticketExpired: + case .accountMismatch, .emailMismatch, .authFailed, .ticketExpired: return true default: return false @@ -166,10 +169,26 @@ extension MobilePairingFailureCategory { "mobile.pairing.accountMismatch", defaultValue: "This Mac is signed in to a different cmux account. Sign out and sign back in with the account that owns this Mac." ) + case let .emailMismatch(expected, actual): + let format = if let actual, !actual.isEmpty { + L10n.string( + "mobile.pairing.emailMismatchFormat", + defaultValue: "This QR is for %@, but this iPhone is signed in as %@. Sign in with the same email as the Mac, then scan again." + ) + } else { + L10n.string( + "mobile.pairing.emailMissingFormat", + defaultValue: "This QR is for %@. Sign in with the same email as the Mac, then scan again." + ) + } + if let actual, !actual.isEmpty { + return String(format: format, expected, actual) + } + return String(format: format, expected) case .authFailed: return L10n.string( "mobile.pairing.authorizationFailed", - defaultValue: "Couldn't verify your account with this Mac. Make sure both devices use the same cmux account and a matching build (both release, or both development), then try again." + defaultValue: "Couldn't verify your account with this Mac. Make sure both devices are signed in with the same email, then try again." ) case .ticketExpired: return L10n.string( @@ -232,7 +251,7 @@ extension MobilePairingFailureCategory { "mobile.pairing.guidance.localNetwork", defaultValue: "Settings > cmux > Local Network, then try again." ) - case .accountMismatch, .authFailed: + case .accountMismatch, .emailMismatch, .authFailed: return L10n.string( "mobile.pairing.guidance.sameAccount", defaultValue: "Both devices must be signed in to the same cmux account." diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index f9753bbc429..f9b1e937643 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -126,6 +126,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// "Check that both devices are on the same Tailscale"). Set and cleared /// together with the error by the pairing-failure classifier sink. public private(set) var connectionErrorGuidance: String? + /// A warning that must be accepted before pairing continues, currently used + /// for Mac/iPhone app-version skew. + public private(set) var pairingVersionWarning: String? public private(set) var activeTicket: CmxAttachTicket? public private(set) var activeRoute: CmxAttachRoute? @@ -436,6 +439,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// reading it again would report the first successful pair as `is_first_pair: /// false` and break the first-pair funnel. private var pairingAttemptIsFirstPair = false + private var pendingPairingVersionWarningURL: String? /// The structured diagnostic log, injected from the app composition root. /// @@ -608,6 +612,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.terminalInputText = "" self.connectionError = nil self.connectionErrorGuidance = nil + self.pairingVersionWarning = nil self.activeTicket = nil self.activeRoute = nil self.selectedWorkspaceID = workspaces.first?.id @@ -686,6 +691,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { macConnectionStatus = .unavailable connectedHostName = "" pairingCode = "" + clearPairingVersionWarning() // Wipe every saved draft so the next account never sees the previous // user's unsent text. Guard the in-memory clear (and the selection resets // below) so the per-terminal draft hooks do not write partial state into a @@ -1906,6 +1912,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalID: ticket.terminalID, macDeviceID: reportedID, macDisplayName: ticket.macDisplayName, + macUserEmail: ticket.macUserEmail, + macAppVersion: ticket.macAppVersion, + macAppBuild: ticket.macAppBuild, routes: ticket.routes, expiresAt: ticket.expiresAt, authToken: ticket.authToken @@ -1998,6 +2007,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { @discardableResult public func connectPairingURLResult(_ rawValue: String? = nil) async -> MobilePairingURLConnectionResult { + await connectPairingURLResult(rawValue, acceptedVersionWarning: false) + } + + @discardableResult + private func connectPairingURLResult( + _ rawValue: String? = nil, + acceptedVersionWarning: Bool + ) async -> MobilePairingURLConnectionResult { let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) let attemptID = beginPairingAttempt(method: "qr") let ticket: CmxAttachTicket @@ -2034,6 +2051,27 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return .failed } + if let emailFailure = Self.emailFailure(for: ticket, actualEmail: identityProvider?.currentUserEmail) { + guard isCurrentPairingAttempt(attemptID) else { return .superseded } + applyPairingFailure(emailFailure, phase: "validation") + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + return .failed + } + + if !acceptedVersionWarning, + let warning = versionWarning(for: ticket) { + guard isCurrentPairingAttempt(attemptID) else { return .superseded } + pendingPairingVersionWarningURL = rawURL + pairingVersionWarning = warning + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + return .needsUserApproval + } + clearPairingVersionWarning() + // Offline preflight: fail fast instead of stacking per-route connect // timeouts into the opaque ~60s wait. Skipped only when no route is // dialable so `connect()` classifies that as `no_supported_route`. @@ -2087,11 +2125,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public func cancelPairing() { invalidatePairingAttempt() clearPairingError() + clearPairingVersionWarning() connectionState = .disconnected macConnectionStatus = .unavailable clearRemoteConnectionContext() } + public func acceptPairingVersionWarning() async { + guard let rawURL = pendingPairingVersionWarningURL else { + clearPairingVersionWarning() + return + } + clearPairingVersionWarning() + await connectPairingURLResult(rawURL, acceptedVersionWarning: true) + } + /// Tear down the live connection and reset connection UI state, without /// touching the paired-Mac store or the restoring-gate hint. The switcher's /// ``forgetMac(macDeviceID:)`` and ``switchToMac(macDeviceID:)`` reuse this, @@ -2987,6 +3035,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { cancelRemoteOperationTasks() rawTerminalInputBuffer.clear() clearPairingError() + clearPairingVersionWarning() if let method { pairingAttemptStartedAt = runtime?.now() ?? Date() pairingAttemptMethod = method @@ -3084,6 +3133,54 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionErrorGuidance = nil } + private func clearPairingVersionWarning() { + pairingVersionWarning = nil + pendingPairingVersionWarningURL = nil + } + + static func emailFailure( + for ticket: CmxAttachTicket, + actualEmail: String? + ) -> MobilePairingFailureCategory? { + guard let expected = normalizedEmail(ticket.macUserEmail) else { return nil } + guard normalizedEmail(actualEmail) == expected else { + return .emailMismatch(expected: expected, actual: normalizedEmail(actualEmail)) + } + return nil + } + + private func versionWarning(for ticket: CmxAttachTicket) -> String? { + guard let macVersion = Self.normalizedNonEmpty(ticket.macAppVersion) else { return nil } + let phoneStamp = feedbackStampProvider() + guard let phoneVersion = Self.normalizedNonEmpty(phoneStamp.appVersion), + phoneVersion != macVersion else { + return nil + } + let format = L10n.string( + "mobile.pairing.versionWarningFormat", + defaultValue: "This iPhone is running cmux %@, but the Mac is running cmux %@. Pairing across different versions can break terminal input, workspace sync, or notifications. Continue only if you trust this Mac and accept that some features may fail." + ) + return String( + format: format, + Self.versionDisplay(version: phoneVersion, build: phoneStamp.appBuild), + Self.versionDisplay(version: macVersion, build: ticket.macAppBuild) + ) + } + + private static func versionDisplay(version: String, build: String?) -> String { + guard let build = normalizedNonEmpty(build) else { return version } + return "\(version) (\(build))" + } + + private static func normalizedEmail(_ value: String?) -> String? { + normalizedNonEmpty(value)?.lowercased() + } + + private static func normalizedNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + /// Record an `ios_pairing_failed` for a `connect()` that returned without /// connecting and already set a specific ``connectionError``: emits the reason /// `connect()` reported (fallback `other`) without overwriting the message. diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift index a92e52f326e..92c0da14817 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift @@ -122,6 +122,17 @@ import Testing #expect(category.isAuthorizationFailure) } + @Test func emailMismatchIsAuthorizationFailure() { + let category = MobilePairingFailureCategory.emailMismatch( + expected: "mac@example.com", + actual: "phone@example.com" + ) + #expect(category.analyticsReason == "email_mismatch") + #expect(category.isAuthorizationFailure) + #expect(category.message.contains("mac@example.com")) + #expect(category.message.contains("phone@example.com")) + } + @Test func insecureManualRouteIsUnsupportedRoute() { let category = MobilePairingFailureCategory.classify( error: MobileShellConnectionError.insecureManualRoute, @@ -190,6 +201,7 @@ import Testing .handshakeTimedOut(host: "h", port: 1), .connectionDropped(host: "h", port: 1), .accountMismatch, + .emailMismatch(expected: "mac@example.com", actual: "phone@example.com"), .authFailed, .ticketExpired, .invalidCode, diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingURLConnectionResult.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingURLConnectionResult.swift index 57ccc2d3ed7..6a32cc72f06 100644 --- a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingURLConnectionResult.swift +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingURLConnectionResult.swift @@ -6,6 +6,8 @@ public enum MobilePairingURLConnectionResult: Equatable, Sendable { case connected /// The pairing URL failed to connect. case failed + /// The pairing URL is waiting for explicit user approval before dialing. + case needsUserApproval /// A newer connection attempt superseded this one before it completed. case superseded diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index e0341b0d0ce..aeb37b6736e 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -191,9 +191,13 @@ struct CMUXMobileRootView: View { pairingCode: $store.pairingCode, connectionError: store.connectionError, connectionErrorGuidance: store.connectionErrorGuidance, + versionWarning: store.pairingVersionWarning, connectPairingCode: { await store.connectPairingInput() }, + acceptVersionWarning: { + await store.acceptPairingVersionWarning() + }, connectManualHost: { name, host, port in await store.connectManualHost(name: name, host: host, port: port) }, diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift index 602085f78a3..5d5d38ee4c4 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift @@ -18,7 +18,9 @@ struct PairingView: View { /// (for example "Check that both devices are on the same Tailscale"). `nil` /// when the headline is already the full instruction. let connectionErrorGuidance: String? + let versionWarning: String? let connectPairingCode: () async -> Void + let acceptVersionWarning: () async -> Void let connectManualHost: (String, String, Int) async -> Void let cancelPairing: () -> Void let cancel: () -> Void @@ -142,6 +144,34 @@ struct PairingView: View { } } + if let versionWarning { + Section { + VStack(alignment: .leading, spacing: 10) { + Label { + Text(L10n.string("mobile.pairing.versionWarningTitle", defaultValue: "Version mismatch")) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.headline) + .foregroundStyle(.orange) + + Text(versionWarning) + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityIdentifier("MobilePairingVersionWarning") + + Button(role: .destructive) { + startPairingTask { + await acceptVersionWarning() + } + } label: { + Text(L10n.string("mobile.pairing.versionWarningContinue", defaultValue: "Continue anyway")) + } + .accessibilityIdentifier("MobilePairingVersionWarningContinueButton") + } + } + } + if let errorText { Section { VStack(alignment: .leading, spacing: 8) { diff --git a/Packages/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift b/Packages/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift index 3a2c6a2bde2..63a9fda1479 100644 --- a/Packages/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift +++ b/Packages/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift @@ -65,6 +65,8 @@ public struct MobileRootAuthGate { return connectionState != .connected || !hasActiveUnexpiredTicket case .failed: return true + case .needsUserApproval: + return false case .superseded: return connectionState != .connected || !hasActiveUnexpiredTicket } diff --git a/Packages/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift b/Packages/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift index 49e2591ab83..03ff006a565 100644 --- a/Packages/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift +++ b/Packages/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift @@ -57,6 +57,11 @@ import Testing connectionState: .disconnected, hasActiveUnexpiredTicket: false )) + #expect(!MobileRootAuthGate.shouldClearAttachTicketAuthentication( + pairingResult: .needsUserApproval, + connectionState: .disconnected, + hasActiveUnexpiredTicket: false + )) #expect(!MobileRootAuthGate.shouldClearAttachTicketAuthentication( pairingResult: .superseded, connectionState: .connected, diff --git a/Sources/Mobile/MobileAttachTicketStore.swift b/Sources/Mobile/MobileAttachTicketStore.swift index 36d1ec86a84..da8c8ee3562 100644 --- a/Sources/Mobile/MobileAttachTicketStore.swift +++ b/Sources/Mobile/MobileAttachTicketStore.swift @@ -27,6 +27,9 @@ final class MobileAttachTicketStore { terminalID: String?, routes: [CmxAttachRoute], ttl: TimeInterval, + macUserEmail: String? = nil, + macAppVersion: String? = nil, + macAppBuild: String? = nil, now: Date = Date() ) throws -> CmxAttachTicket { lock.lock() @@ -42,6 +45,9 @@ final class MobileAttachTicketStore { terminalID: terminalID, macDeviceID: MobileHostIdentity.deviceID(), macDisplayName: MobileHostIdentity.displayName(), + macUserEmail: macUserEmail, + macAppVersion: macAppVersion, + macAppBuild: macAppBuild, routes: routes, expiresAt: now.addingTimeInterval(max(30, ttl)), authToken: Self.randomBearerToken() diff --git a/Sources/Mobile/MobileHostBuildIdentity.swift b/Sources/Mobile/MobileHostBuildIdentity.swift new file mode 100644 index 00000000000..71ac19dcac6 --- /dev/null +++ b/Sources/Mobile/MobileHostBuildIdentity.swift @@ -0,0 +1,18 @@ +import Foundation + +struct MobileHostBuildIdentity { + let appVersion: String? + let appBuild: String? + + static func current(bundle: Bundle = .main) -> MobileHostBuildIdentity { + MobileHostBuildIdentity( + appVersion: normalized(bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String), + appBuild: normalized(bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String) + ) + } + + private static func normalized(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 89642e1d929..4a0d975fabe 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -320,6 +320,13 @@ final class MobileHostService { if let displayName = MobileHostIdentity.displayName() { payload["mac_display_name"] = displayName } + let build = MobileHostBuildIdentity.current() + if let appVersion = build.appVersion { + payload["mac_app_version"] = appVersion + } + if let appBuild = build.appBuild { + payload["mac_app_build"] = appBuild + } return payload } @@ -1060,7 +1067,10 @@ final class MobileHostService { workspaceID: workspaceID, terminalID: terminalID, routes: selectedRoutes, - ttl: ttl + ttl: ttl, + macUserEmail: await currentAuthenticatedLocalUserEmail(), + macAppVersion: MobileHostBuildIdentity.current().appVersion, + macAppBuild: MobileHostBuildIdentity.current().appBuild ) return try ticketStore.payload(for: ticket) } @@ -1587,6 +1597,7 @@ final class MobileHostService { } } + #if DEBUG extension MobileHostService { func debugResetMobileLifecycleStateForTesting() { @@ -1664,14 +1675,19 @@ private enum MobileHostAuthorizationError: Error { } enum MobileHostAuthorizationPolicy { - static func authorizeStackUser(localUserID: String?, remoteUserID: String) throws { - guard let localUserID, !localUserID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + static func authorizeStackEmail(localEmail: String?, remoteEmail: String?) throws { + guard let localEmail = normalizedEmail(localEmail) else { throw MobileHostAuthorizationError.missingLocalUser } - guard localUserID == remoteUserID else { + guard normalizedEmail(remoteEmail) == localEmail else { throw MobileHostAuthorizationError.accountMismatch } } + + private static func normalizedEmail(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed?.isEmpty == false ? trimmed : nil + } } #if DEBUG @@ -1695,7 +1711,7 @@ private actor MobileHostStackAuthVerifier { private static let verificationTimeoutNanoseconds: UInt64 = 10 * 1_000_000_000 private struct CacheEntry { - let userID: String + let email: String? let expiresAt: Date } @@ -1716,10 +1732,10 @@ private actor MobileHostStackAuthVerifier { cached.expiresAt > Date() else { return nil } - let localUserID = await currentAuthenticatedLocalUserID() - return (try? MobileHostAuthorizationPolicy.authorizeStackUser( - localUserID: localUserID, - remoteUserID: cached.userID + let localEmail = await currentAuthenticatedLocalUserEmail() + return (try? MobileHostAuthorizationPolicy.authorizeStackEmail( + localEmail: localEmail, + remoteEmail: cached.email )) != nil } @@ -1730,10 +1746,10 @@ private actor MobileHostStackAuthVerifier { let cacheKey = Self.cacheKey(for: accessToken) let now = Date() - let remoteUserID: String + let remoteEmail: String? cache = cache.filter { $0.value.expiresAt > now } if let cached = cache[cacheKey], cached.expiresAt > now { - remoteUserID = cached.userID + remoteEmail = cached.email // Refresh-ahead: when the cached binding is near expiry, re-verify in // the background so an actively-typing client never blocks a keystroke // on the network round-trip. Every mobile request now requires Stack @@ -1742,29 +1758,29 @@ private actor MobileHostStackAuthVerifier { scheduleRefreshAhead(cacheKey: cacheKey, accessToken: accessToken) } } else { - remoteUserID = try await fetchAndCacheRemoteUserID(cacheKey: cacheKey, accessToken: accessToken) + remoteEmail = try await fetchAndCacheRemoteEmail(cacheKey: cacheKey, accessToken: accessToken) } - let localUserID = await currentAuthenticatedLocalUserID() - try MobileHostAuthorizationPolicy.authorizeStackUser( - localUserID: localUserID, - remoteUserID: remoteUserID + let localEmail = await currentAuthenticatedLocalUserEmail() + try MobileHostAuthorizationPolicy.authorizeStackEmail( + localEmail: localEmail, + remoteEmail: remoteEmail ) } - private func fetchAndCacheRemoteUserID(cacheKey: String, accessToken: String) async throws -> String { + private func fetchAndCacheRemoteEmail(cacheKey: String, accessToken: String) async throws -> String? { let stack = Self.makeStackClient(accessToken: accessToken) guard let user = try await Self.withVerificationTimeout({ try await stack.getUser(or: .throw) }) else { throw MobileHostAuthorizationError.invalidStackUser } - let remoteUserID = await user.id + let remoteEmail = await user.primaryEmail cache[cacheKey] = CacheEntry( - userID: remoteUserID, + email: remoteEmail, expiresAt: Date().addingTimeInterval(Self.cacheTTLSeconds) ) - return remoteUserID + return remoteEmail } private func scheduleRefreshAhead(cacheKey: String, accessToken: String) { @@ -1776,7 +1792,7 @@ private actor MobileHostStackAuthVerifier { private func refreshAhead(cacheKey: String, accessToken: String) async { defer { refreshingKeys.remove(cacheKey) } // Best-effort: on failure leave the existing entry to expire naturally. - _ = try? await fetchAndCacheRemoteUserID(cacheKey: cacheKey, accessToken: accessToken) + _ = try? await fetchAndCacheRemoteEmail(cacheKey: cacheKey, accessToken: accessToken) } private static func makeStackClient(accessToken: String) -> StackClientApp { @@ -1825,8 +1841,8 @@ private actor MobileHostStackAuthVerifier { } } - private func currentAuthenticatedLocalUserID() async -> String? { - await MobileHostService.shared.currentAuthenticatedLocalUserID() + private func currentAuthenticatedLocalUserEmail() async -> String? { + await MobileHostService.shared.currentAuthenticatedLocalUserEmail() } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 026dc12e411..42138c266d6 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -446,6 +446,7 @@ C0DE3150A00000000000001 /* MinimalModeSidebarControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE3150A00000000000002 /* MinimalModeSidebarControls.swift */; }; 284FD21ACF8BD1ED6AEBD7A0 /* MobileAttachTicketStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6D2AC715764ECC1F6B26A2 /* MobileAttachTicketStore.swift */; }; 6AA2F2E8BEB19ED6B8E125DB /* MobileHostAuthorizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD519CFE74530A46DF0925D /* MobileHostAuthorizationTests.swift */; }; + C0DE1D010000000000000001 /* MobileHostBuildIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1D010000000000000002 /* MobileHostBuildIdentity.swift */; }; 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */; }; C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */; }; F67FBC88671C40AC3A3284CE /* MobileHostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ACA497A6831165EA879C642 /* MobileHostService.swift */; }; @@ -1258,6 +1259,7 @@ C0DE3150A00000000000002 /* MinimalModeSidebarControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/MinimalModeSidebarControls.swift; sourceTree = ""; }; 8D6D2AC715764ECC1F6B26A2 /* MobileAttachTicketStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileAttachTicketStore.swift; sourceTree = ""; }; 9BD519CFE74530A46DF0925D /* MobileHostAuthorizationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostAuthorizationTests.swift; sourceTree = ""; }; + C0DE1D010000000000000002 /* MobileHostBuildIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostBuildIdentity.swift; sourceTree = ""; }; 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostRPC.swift; sourceTree = ""; }; C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+Capabilities.swift"; sourceTree = ""; }; 7ACA497A6831165EA879C642 /* MobileHostService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostService.swift; sourceTree = ""; }; @@ -1761,6 +1763,7 @@ children = ( 8D6D2AC715764ECC1F6B26A2 /* MobileAttachTicketStore.swift */, 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */, + C0DE1D010000000000000002 /* MobileHostBuildIdentity.swift */, 7ACA497A6831165EA879C642 /* MobileHostService.swift */, 4C1A7E10B2D34F56A8C90012 /* MobileHostStatusVerificationLimiter.swift */, C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */, @@ -3127,6 +3130,7 @@ 3865A0033865A0033865A003 /* MenubarSearchPopover.swift in Sources */, C0DE3150A00000000000001 /* MinimalModeSidebarControls.swift in Sources */, 284FD21ACF8BD1ED6AEBD7A0 /* MobileAttachTicketStore.swift in Sources */, + C0DE1D010000000000000001 /* MobileHostBuildIdentity.swift in Sources */, 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */, C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */, F67FBC88671C40AC3A3284CE /* MobileHostService.swift in Sources */, diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index ad5f535d3db..52e4c4046dd 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -709,27 +709,27 @@ final class MobileHostAuthorizationTests: XCTestCase { XCTAssertNil(error) } - func testStackUserAuthorizationRequiresSignedInMacUser() throws { + func testStackEmailAuthorizationRequiresSignedInMacUser() throws { XCTAssertThrowsError( - try MobileHostAuthorizationPolicy.authorizeStackUser( - localUserID: nil, - remoteUserID: "user_remote" + try MobileHostAuthorizationPolicy.authorizeStackEmail( + localEmail: nil, + remoteEmail: "user@example.com" ) ) } - func testStackUserAuthorizationRequiresMatchingUser() throws { + func testStackEmailAuthorizationRequiresMatchingEmail() throws { XCTAssertThrowsError( - try MobileHostAuthorizationPolicy.authorizeStackUser( - localUserID: "user_local", - remoteUserID: "user_remote" + try MobileHostAuthorizationPolicy.authorizeStackEmail( + localEmail: "local@example.com", + remoteEmail: "remote@example.com" ) ) XCTAssertNoThrow( - try MobileHostAuthorizationPolicy.authorizeStackUser( - localUserID: "user_local", - remoteUserID: "user_local" + try MobileHostAuthorizationPolicy.authorizeStackEmail( + localEmail: " User@Example.com ", + remoteEmail: "user@example.com" ) ) } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ff95a1456c0..2bd39409d12 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -1265,13 +1265,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Couldn't verify your account with this Mac. Make sure both devices use the same cmux account and a matching build (both release, or both development), then try again." + "value": "Couldn't verify your account with this Mac. Make sure both devices are signed in with the same email, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "この Mac でアカウントを確認できませんでした。両方のデバイスで同じ cmux アカウントと一致するビルド(どちらもリリース版、またはどちらも開発版)を使用していることを確認してから、もう一度お試しください。" + "value": "この Mac でアカウントを確認できませんでした。両方のデバイスが同じメールアドレスでサインインしていることを確認してから、もう一度お試しください。" } } } @@ -4183,6 +4183,91 @@ } } }, + "mobile.pairing.emailMismatchFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This QR is for %@, but this iPhone is signed in as %@. Sign in with the same email as the Mac, then scan again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この QR は %@ 用ですが、この iPhone は %@ としてサインインしています。Mac と同じメールアドレスでサインインしてから、もう一度スキャンしてください。" + } + } + } + }, + "mobile.pairing.emailMissingFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This QR is for %@. Sign in with the same email as the Mac, then scan again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この QR は %@ 用です。Mac と同じメールアドレスでサインインしてから、もう一度スキャンしてください。" + } + } + } + }, + "mobile.pairing.versionWarningContinue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue anyway" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "それでも続行" + } + } + } + }, + "mobile.pairing.versionWarningFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This iPhone is running cmux %@, but the Mac is running cmux %@. Pairing across different versions can break terminal input, workspace sync, or notifications. Continue only if you trust this Mac and accept that some features may fail." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この iPhone は cmux %@ を実行していますが、Mac は cmux %@ を実行しています。異なるバージョン間でペアリングすると、ターミナル入力、ワークスペース同期、通知が壊れる可能性があります。この Mac を信頼し、一部の機能が失敗する可能性を受け入れる場合のみ続行してください。" + } + } + } + }, + "mobile.pairing.versionWarningTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version mismatch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョンが一致していません" + } + } + } + }, "mobile.pairing.appNotRunning": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 79ff0238d27..1de59d8ccf4 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -1404,6 +1404,89 @@ final class TerminalOutputCollector { #expect(store.hasKnownPairedMac) } +@MainActor +@Test func minimalPairingCodeRequiresMatchingEmailBeforeDialing() async throws { + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "qr-workspace", title: "QR Workspace"), + ]) + let runtime = testRuntime( + supportedRouteKinds: [.tailscale], + transportFactory: ScriptedTransportFactory(responses: responses) + ) + let store = CMUXMobileShellStore( + runtime: runtime, + workspaces: PreviewMobileHost.workspaces, + identityProvider: TestIdentityProvider( + currentUserIDValue: "phone-user", + currentUserEmailValue: "phone@example.com" + ) + ) + + store.signIn() + let result = await store.connectPairingURLResult( + "cmux-ios://attach?v=2&e=mac@example.com&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + ) + + #expect(result == .failed) + #expect(store.connectionState == .disconnected) + #expect(store.connectionError?.contains("mac@example.com") == true) + #expect(store.connectionError?.contains("phone@example.com") == true) + #expect(try await responses.sentRequests().isEmpty) +} + +@MainActor +@Test func minimalPairingCodeVersionMismatchWarnsAndContinuesAfterAcceptance() async throws { + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "qr-workspace", title: "QR Workspace"), + try rpcHostStatusFrame( + renderGrid: false, + macDeviceID: "status-reported-mac", + macDisplayName: "Status Mac" + ), + ]) + let runtime = testRuntime( + supportedRouteKinds: [.tailscale], + transportFactory: ScriptedTransportFactory(responses: responses), + supportsServerPushEvents: false + ) + let store = CMUXMobileShellStore( + runtime: runtime, + workspaces: PreviewMobileHost.workspaces, + identityProvider: TestIdentityProvider( + currentUserIDValue: "phone-user", + currentUserEmailValue: "user@example.com" + ), + feedbackStampProvider: { + MobileFeedbackStamp( + buildType: .dev, + appVersion: "0.65.0", + appBuild: "10", + bundleIdentifier: "dev.cmux.ios.test", + osVersion: "iOS test", + deviceModel: "test" + ) + } + ) + + store.signIn() + let result = await store.connectPairingURLResult( + "cmux-ios://attach?v=2&e=user@example.com&av=0.64.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + ) + + #expect(result == .needsUserApproval) + #expect(store.connectionState == .disconnected) + #expect(store.pairingVersionWarning?.contains("0.65.0 (10)") == true) + #expect(store.pairingVersionWarning?.contains("0.64.0 (9)") == true) + #expect(try await responses.sentRequests().isEmpty) + + await store.acceptPairingVersionWarning() + + #expect(store.pairingVersionWarning == nil) + #expect(store.connectionState == .connected) + #expect(store.selectedWorkspace?.id.rawValue == "qr-workspace") + #expect(try await responses.sentRequests().contains { $0.method == "workspace.list" }) +} + @MainActor @Test func minimalPairingCodePersistsPairedMacWithoutServerPushEvents() async throws { // Identity recovery for an anonymous v2 ticket must not be coupled to @@ -2259,6 +2342,14 @@ final class TerminalOutputCollector { private struct MissingTestStackAccessToken: Error {} +private struct TestIdentityProvider: MobileIdentityProviding { + let currentUserIDValue: String? + let currentUserEmailValue: String? + + @MainActor var currentUserID: String? { currentUserIDValue } + @MainActor var currentUserEmail: String? { currentUserEmailValue } +} + private func testRuntime( supportedRouteKinds: [CmxAttachTransportKind] = [.tailscale, .debugLoopback, .websocket], transportFactory: any CmxByteTransportFactory, From 57b8589017bd42a98f5bbff206e718502040c9ef Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:23:09 -0700 Subject: [PATCH 02/31] Address iOS pairing review findings --- .../MobileShellComposite.swift | 8 +++- Sources/Mobile/MobileHostService.swift | 48 +++++++++---------- cmuxTests/MobileHostAuthorizationTests.swift | 22 ++++----- .../cmuxFeatureTests/cmuxFeatureTests.swift | 6 +-- 4 files changed, 44 insertions(+), 40 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index ef7db0ad483..ee6daf29545 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -3149,10 +3149,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private func versionWarning(for ticket: CmxAttachTicket) -> String? { guard let macVersion = Self.normalizedNonEmpty(ticket.macAppVersion) else { return nil } let phoneStamp = feedbackStampProvider() - guard let phoneVersion = Self.normalizedNonEmpty(phoneStamp.appVersion), - phoneVersion != macVersion else { + guard let phoneVersion = Self.normalizedNonEmpty(phoneStamp.appVersion) else { return nil } + let phoneBuild = Self.normalizedNonEmpty(phoneStamp.appBuild) + let macBuild = Self.normalizedNonEmpty(ticket.macAppBuild) + let versionDiffers = phoneVersion != macVersion + let buildDiffers = phoneBuild != nil && macBuild != nil && phoneBuild != macBuild + guard versionDiffers || buildDiffers else { return nil } let format = L10n.string( "mobile.pairing.versionWarningFormat", defaultValue: "This iPhone is running cmux %@, but the Mac is running cmux %@. Pairing across different versions can break terminal input, workspace sync, or notifications. Continue only if you trust this Mac and accept that some features may fail." diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 4a0d975fabe..fe43eedef6a 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1675,17 +1675,17 @@ private enum MobileHostAuthorizationError: Error { } enum MobileHostAuthorizationPolicy { - static func authorizeStackEmail(localEmail: String?, remoteEmail: String?) throws { - guard let localEmail = normalizedEmail(localEmail) else { + static func authorizeStackUserID(localUserID: String?, remoteUserID: String?) throws { + guard let localUserID = normalizedUserID(localUserID) else { throw MobileHostAuthorizationError.missingLocalUser } - guard normalizedEmail(remoteEmail) == localEmail else { + guard normalizedUserID(remoteUserID) == localUserID else { throw MobileHostAuthorizationError.accountMismatch } } - private static func normalizedEmail(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + private static func normalizedUserID(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed?.isEmpty == false ? trimmed : nil } } @@ -1711,7 +1711,7 @@ private actor MobileHostStackAuthVerifier { private static let verificationTimeoutNanoseconds: UInt64 = 10 * 1_000_000_000 private struct CacheEntry { - let email: String? + let userID: String? let expiresAt: Date } @@ -1732,10 +1732,10 @@ private actor MobileHostStackAuthVerifier { cached.expiresAt > Date() else { return nil } - let localEmail = await currentAuthenticatedLocalUserEmail() - return (try? MobileHostAuthorizationPolicy.authorizeStackEmail( - localEmail: localEmail, - remoteEmail: cached.email + let localUserID = await currentAuthenticatedLocalUserID() + return (try? MobileHostAuthorizationPolicy.authorizeStackUserID( + localUserID: localUserID, + remoteUserID: cached.userID )) != nil } @@ -1746,10 +1746,10 @@ private actor MobileHostStackAuthVerifier { let cacheKey = Self.cacheKey(for: accessToken) let now = Date() - let remoteEmail: String? + let remoteUserID: String? cache = cache.filter { $0.value.expiresAt > now } if let cached = cache[cacheKey], cached.expiresAt > now { - remoteEmail = cached.email + remoteUserID = cached.userID // Refresh-ahead: when the cached binding is near expiry, re-verify in // the background so an actively-typing client never blocks a keystroke // on the network round-trip. Every mobile request now requires Stack @@ -1758,29 +1758,29 @@ private actor MobileHostStackAuthVerifier { scheduleRefreshAhead(cacheKey: cacheKey, accessToken: accessToken) } } else { - remoteEmail = try await fetchAndCacheRemoteEmail(cacheKey: cacheKey, accessToken: accessToken) + remoteUserID = try await fetchAndCacheRemoteUserID(cacheKey: cacheKey, accessToken: accessToken) } - let localEmail = await currentAuthenticatedLocalUserEmail() - try MobileHostAuthorizationPolicy.authorizeStackEmail( - localEmail: localEmail, - remoteEmail: remoteEmail + let localUserID = await currentAuthenticatedLocalUserID() + try MobileHostAuthorizationPolicy.authorizeStackUserID( + localUserID: localUserID, + remoteUserID: remoteUserID ) } - private func fetchAndCacheRemoteEmail(cacheKey: String, accessToken: String) async throws -> String? { + private func fetchAndCacheRemoteUserID(cacheKey: String, accessToken: String) async throws -> String? { let stack = Self.makeStackClient(accessToken: accessToken) guard let user = try await Self.withVerificationTimeout({ try await stack.getUser(or: .throw) }) else { throw MobileHostAuthorizationError.invalidStackUser } - let remoteEmail = await user.primaryEmail + let remoteUserID = await user.id cache[cacheKey] = CacheEntry( - email: remoteEmail, + userID: remoteUserID, expiresAt: Date().addingTimeInterval(Self.cacheTTLSeconds) ) - return remoteEmail + return remoteUserID } private func scheduleRefreshAhead(cacheKey: String, accessToken: String) { @@ -1792,7 +1792,7 @@ private actor MobileHostStackAuthVerifier { private func refreshAhead(cacheKey: String, accessToken: String) async { defer { refreshingKeys.remove(cacheKey) } // Best-effort: on failure leave the existing entry to expire naturally. - _ = try? await fetchAndCacheRemoteEmail(cacheKey: cacheKey, accessToken: accessToken) + _ = try? await fetchAndCacheRemoteUserID(cacheKey: cacheKey, accessToken: accessToken) } private static func makeStackClient(accessToken: String) -> StackClientApp { @@ -1841,8 +1841,8 @@ private actor MobileHostStackAuthVerifier { } } - private func currentAuthenticatedLocalUserEmail() async -> String? { - await MobileHostService.shared.currentAuthenticatedLocalUserEmail() + private func currentAuthenticatedLocalUserID() async -> String? { + await MobileHostService.shared.currentAuthenticatedLocalUserID() } } diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index 6185dbdad71..e0010c570d8 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -709,27 +709,27 @@ final class MobileHostAuthorizationTests: XCTestCase { XCTAssertNil(error) } - func testStackEmailAuthorizationRequiresSignedInMacUser() throws { + func testStackUserIDAuthorizationRequiresSignedInMacUser() throws { XCTAssertThrowsError( - try MobileHostAuthorizationPolicy.authorizeStackEmail( - localEmail: nil, - remoteEmail: "user@example.com" + try MobileHostAuthorizationPolicy.authorizeStackUserID( + localUserID: nil, + remoteUserID: "user_123" ) ) } - func testStackEmailAuthorizationRequiresMatchingEmail() throws { + func testStackUserIDAuthorizationRequiresMatchingUserID() throws { XCTAssertThrowsError( - try MobileHostAuthorizationPolicy.authorizeStackEmail( - localEmail: "local@example.com", - remoteEmail: "remote@example.com" + try MobileHostAuthorizationPolicy.authorizeStackUserID( + localUserID: "user_local", + remoteUserID: "user_remote" ) ) XCTAssertNoThrow( - try MobileHostAuthorizationPolicy.authorizeStackEmail( - localEmail: " User@Example.com ", - remoteEmail: "user@example.com" + try MobileHostAuthorizationPolicy.authorizeStackUserID( + localUserID: " user_123 ", + remoteUserID: "user_123" ) ) } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 5ca71b88ba7..4c175a303f8 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -1435,7 +1435,7 @@ final class TerminalOutputCollector { } @MainActor -@Test func minimalPairingCodeVersionMismatchWarnsAndContinuesAfterAcceptance() async throws { +@Test func minimalPairingCodeBuildMismatchWarnsAndContinuesAfterAcceptance() async throws { let responses = ScriptedTransportResponses([ try rpcWorkspaceListFrame(workspaceID: "qr-workspace", title: "QR Workspace"), try rpcHostStatusFrame( @@ -1470,13 +1470,13 @@ final class TerminalOutputCollector { store.signIn() let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&e=user@example.com&av=0.64.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&e=user@example.com&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .needsUserApproval) #expect(store.connectionState == .disconnected) #expect(store.pairingVersionWarning?.contains("0.65.0 (10)") == true) - #expect(store.pairingVersionWarning?.contains("0.64.0 (9)") == true) + #expect(store.pairingVersionWarning?.contains("0.65.0 (9)") == true) #expect(try await responses.sentRequests().isEmpty) await store.acceptPairingVersionWarning() From b5b1fa30b1883dfb9288d3973be60f8deaf2cd6b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:27:54 -0700 Subject: [PATCH 03/31] Fix pairing version warning lifecycle --- .../MobileShellComposite.swift | 14 +++-- .../CMUXMobileRootView.swift | 31 +++++++--- .../cmuxFeatureTests/cmuxFeatureTests.swift | 62 +++++++++++++++++++ 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index ee6daf29545..c1ff5f6c012 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2064,9 +2064,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { guard isCurrentPairingAttempt(attemptID) else { return .superseded } pendingPairingVersionWarningURL = rawURL pairingVersionWarning = warning - connectionState = .disconnected - macConnectionStatus = .unavailable - clearRemoteConnectionContext() return .needsUserApproval } clearPairingVersionWarning() @@ -2124,19 +2121,24 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public func cancelPairing() { invalidatePairingAttempt() clearPairingError() + if pairingVersionWarning != nil || pendingPairingVersionWarningURL != nil { + clearPairingVersionWarning() + return + } clearPairingVersionWarning() connectionState = .disconnected macConnectionStatus = .unavailable clearRemoteConnectionContext() } - public func acceptPairingVersionWarning() async { + @discardableResult + public func acceptPairingVersionWarning() async -> MobilePairingURLConnectionResult { guard let rawURL = pendingPairingVersionWarningURL else { clearPairingVersionWarning() - return + return .failed } clearPairingVersionWarning() - await connectPairingURLResult(rawURL, acceptedVersionWarning: true) + return await connectPairingURLResult(rawURL, acceptedVersionWarning: true) } /// Tear down the live connection and reset connection UI state, without diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 660bb31e448..f88cb7b9bff 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -196,12 +196,13 @@ struct CMUXMobileRootView: View { await store.connectPairingInput() }, acceptVersionWarning: { - await store.acceptPairingVersionWarning() + let result = await store.acceptPairingVersionWarning() + clearAttachTicketAuthentication(after: result) }, connectManualHost: { name, host, port in await store.connectManualHost(name: name, host: host, port: port) }, - cancelPairing: store.cancelPairing, + cancelPairing: cancelPairing, cancel: { isShowingAddDeviceSheet = false } ) #if os(iOS) @@ -338,16 +339,28 @@ struct CMUXMobileRootView: View { syncShellAuthentication(true) Task { let result = await store.connectPairingURLResult(rawURL) - guard MobileRootAuthGate.shouldClearAttachTicketAuthentication( - pairingResult: result, - connectionState: store.connectionState, - hasActiveUnexpiredTicket: store.hasActiveUnexpiredAttachTicket - ) else { return } - didAuthenticateWithAttachTicket = false - syncShellAuthentication(authManager.isAuthenticated) + if result == .needsUserApproval { + isShowingAddDeviceSheet = true + } + clearAttachTicketAuthentication(after: result) } } + private func cancelPairing() { + store.cancelPairing() + clearAttachTicketAuthenticationIfNeeded() + } + + private func clearAttachTicketAuthentication(after result: MobilePairingURLConnectionResult) { + guard MobileRootAuthGate.shouldClearAttachTicketAuthentication( + pairingResult: result, + connectionState: store.connectionState, + hasActiveUnexpiredTicket: store.hasActiveUnexpiredAttachTicket + ) else { return } + didAuthenticateWithAttachTicket = false + syncShellAuthentication(authManager.isAuthenticated) + } + private func clearAttachTicketAuthenticationIfNeeded() { guard didAuthenticateWithAttachTicket, store.connectionState != .connected || !store.hasActiveUnexpiredAttachTicket else { diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 4c175a303f8..13314a63b10 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -350,6 +350,68 @@ final class TerminalOutputCollector { #expect(store.activeTicket?.macDeviceID == "second-mac") } +@MainActor +@Test func versionWarningDoesNotClearExistingConnectionBeforeApproval() async throws { + let route = try CmxAttachRoute( + id: "debug_loopback", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56577) + ) + let activeTicket = try CmxAttachTicket( + workspaceID: "active-workspace", + terminalID: "active-terminal", + macDeviceID: "active-mac", + macDisplayName: "Active Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60), + authToken: "active-ticket-secret" + ) + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "active-workspace", title: "Active Workspace"), + ]) + let runtime = testRuntime( + supportedRouteKinds: [.debugLoopback, .tailscale], + transportFactory: ScriptedTransportFactory(responses: responses) + ) + let store = CMUXMobileShellStore( + runtime: runtime, + workspaces: PreviewMobileHost.workspaces, + feedbackStampProvider: { + MobileFeedbackStamp( + buildType: .dev, + appVersion: "0.65.0", + appBuild: "10", + bundleIdentifier: "dev.cmux.ios.test", + osVersion: "iOS test", + deviceModel: "test" + ) + } + ) + + store.signIn() + let firstResult = await store.connectPairingURLResult(try attachURL(for: activeTicket).absoluteString) + + #expect(firstResult == .connected) + #expect(store.connectionState == .connected) + #expect(store.activeTicket?.macDeviceID == "active-mac") + + let warningResult = await store.connectPairingURLResult( + "cmux-ios://attach?v=2&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + ) + + #expect(warningResult == .needsUserApproval) + #expect(store.connectionState == .connected) + #expect(store.activeTicket?.macDeviceID == "active-mac") + #expect(store.pairingVersionWarning != nil) + #expect(try await responses.sentRequests().count == 1) + + store.cancelPairing() + + #expect(store.pairingVersionWarning == nil) + #expect(store.connectionState == .connected) + #expect(store.activeTicket?.macDeviceID == "active-mac") +} + @MainActor @Test func attachURLWithoutPathStillConnects() async throws { let route = try CmxAttachRoute( From de56d8e72b97a1c1dd1881a624f06fc5d15283ae Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:33:58 -0700 Subject: [PATCH 04/31] Keep pairing warning sheet reachable --- .../CMUXMobileRootView.swift | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index f88cb7b9bff..f0fa48a7ca5 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -72,6 +72,9 @@ struct CMUXMobileRootView: View { var body: some View { rootContent + .sheet(isPresented: addDeviceSheetBinding) { + pairingSheet + } .animation(.snappy(duration: 0.18), value: isAuthenticated) .animation(.snappy(duration: 0.18), value: store.phase) .onAppear { @@ -186,30 +189,6 @@ struct CMUXMobileRootView: View { setupHelpHighlight: disconnectedSetupHelpHighlight, store: store ) - .sheet(isPresented: $isShowingAddDeviceSheet) { - PairingView( - pairingCode: $store.pairingCode, - connectionError: store.connectionError, - connectionErrorGuidance: store.connectionErrorGuidance, - versionWarning: store.pairingVersionWarning, - connectPairingCode: { - await store.connectPairingInput() - }, - acceptVersionWarning: { - let result = await store.acceptPairingVersionWarning() - clearAttachTicketAuthentication(after: result) - }, - connectManualHost: { name, host, port in - await store.connectManualHost(name: name, host: host, port: port) - }, - cancelPairing: cancelPairing, - cancel: { isShowingAddDeviceSheet = false } - ) - #if os(iOS) - .presentationDetents([.medium, .large], selection: $addDeviceSheetDetent) - .presentationDragIndicator(.visible) - #endif - } .onAppear { showAddDevice() } @@ -218,6 +197,48 @@ struct CMUXMobileRootView: View { } } + private var addDeviceSheetBinding: Binding { + Binding( + get: { isShowingAddDeviceSheet }, + set: { isPresented in + if isPresented { + showAddDevice() + } else { + dismissAddDeviceSheet() + } + } + ) + } + + @ViewBuilder + private var pairingSheet: some View { + PairingView( + pairingCode: $store.pairingCode, + connectionError: store.connectionError, + connectionErrorGuidance: store.connectionErrorGuidance, + versionWarning: store.pairingVersionWarning, + connectPairingCode: { + await store.connectPairingInput() + }, + acceptVersionWarning: { + let result = await store.acceptPairingVersionWarning() + clearAttachTicketAuthentication(after: result) + if result == .connected { + dismissAddDeviceSheet() + } + }, + connectManualHost: { name, host, port in + await store.connectManualHost(name: name, host: host, port: port) + }, + cancelPairing: cancelPairing, + cancel: dismissAddDeviceSheet + ) + #if os(iOS) + .presentationDetents([.medium, .large], selection: $addDeviceSheetDetent) + .presentationDragIndicator(.visible) + #endif + } + /// Which setup gate the disconnected screen's "Trouble connecting?" help marks /// as the user's current step. When the host rejected this device on /// authorization grounds (a different cmux account, or a token it could not @@ -351,6 +372,15 @@ struct CMUXMobileRootView: View { clearAttachTicketAuthenticationIfNeeded() } + private func dismissAddDeviceSheet() { + isShowingAddDeviceSheet = false + if store.pairingVersionWarning != nil { + cancelPairing() + } else { + clearAttachTicketAuthenticationIfNeeded() + } + } + private func clearAttachTicketAuthentication(after result: MobilePairingURLConnectionResult) { guard MobileRootAuthGate.shouldClearAttachTicketAuthentication( pairingResult: result, From 0baf8a7004883764c236b8f70cdc4b21b614292e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:38:33 -0700 Subject: [PATCH 05/31] Keep QR version warning preflight non-destructive --- .../MobileShellComposite.swift | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index c1ff5f6c012..9e786a88b28 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2015,7 +2015,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) - let attemptID = beginPairingAttempt(method: "qr") + clearPairingError() + clearPairingVersionWarning() let ticket: CmxAttachTicket do { ticket = try CmxAttachTicketInput.decode(rawURL) @@ -2034,7 +2035,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { throw MobileSyncPairingPayloadError.loopbackRouteRejected } } catch { - guard isCurrentPairingAttempt(attemptID) else { return .superseded } if case MobileSyncPairingPayloadError.loopbackRouteRejected = error { // A scanned/pasted code that only points back at the Mac // itself (127.0.0.1) would make the phone dial itself. Name @@ -2044,29 +2044,32 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } else { applyPairingFailure(.invalidCode, phase: "validation") } - connectionState = .disconnected - macConnectionStatus = .unavailable - clearRemoteConnectionContext() + if connectionState != .connected { + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + } return .failed } if let emailFailure = Self.emailFailure(for: ticket, actualEmail: identityProvider?.currentUserEmail) { - guard isCurrentPairingAttempt(attemptID) else { return .superseded } applyPairingFailure(emailFailure, phase: "validation") - connectionState = .disconnected - macConnectionStatus = .unavailable - clearRemoteConnectionContext() + if connectionState != .connected { + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + } return .failed } if !acceptedVersionWarning, let warning = versionWarning(for: ticket) { - guard isCurrentPairingAttempt(attemptID) else { return .superseded } pendingPairingVersionWarningURL = rawURL pairingVersionWarning = warning return .needsUserApproval } - clearPairingVersionWarning() + + let attemptID = beginPairingAttempt(method: "qr") // Offline preflight: fail fast instead of stacking per-route connect // timeouts into the opaque ~60s wait. Skipped only when no route is From 64e861e99064eb41ae95fd9c68a49ebaec0a3c27 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:42:17 -0700 Subject: [PATCH 06/31] Supersede QR warnings without opening pairing early --- .../MobileShellComposite.swift | 1 + .../CMUXMobileRootView.swift | 2 +- .../cmuxFeatureTests/cmuxFeatureTests.swift | 54 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 9e786a88b28..10f603af98e 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2015,6 +2015,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) + invalidatePairingAttempt() clearPairingError() clearPairingVersionWarning() let ticket: CmxAttachTicket diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index f0fa48a7ca5..f005c3b43c1 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -30,7 +30,7 @@ struct CMUXMobileRootView: View { @State private var pendingAttachURL: String? @State private var didConsumeUITestAttachURL = false @State private var didAuthenticateWithAttachTicket = false - @State private var isShowingAddDeviceSheet = true + @State private var isShowingAddDeviceSheet = false #if os(iOS) @State private var addDeviceSheetDetent: PresentationDetent = .large #endif diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 13314a63b10..2c393563e23 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -412,6 +412,60 @@ final class TerminalOutputCollector { #expect(store.activeTicket?.macDeviceID == "active-mac") } +@MainActor +@Test func versionWarningSupersedesOlderPairingAttemptWithoutConnectingIt() async throws { + let route = try CmxAttachRoute( + id: "debug_loopback", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56577) + ) + let slowTicket = try CmxAttachTicket( + workspaceID: "first-workspace", + terminalID: "first-terminal", + macDeviceID: "first-mac", + macDisplayName: "First Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60) + ) + let router = SupersededAttachURLRouter() + let runtime = testRuntime( + supportedRouteKinds: [.debugLoopback, .tailscale], + transportFactory: RequestAwareTransportFactory(router: router) + ) + let store = CMUXMobileShellStore( + runtime: runtime, + workspaces: PreviewMobileHost.workspaces, + feedbackStampProvider: { + MobileFeedbackStamp( + buildType: .dev, + appVersion: "0.65.0", + appBuild: "10", + bundleIdentifier: "dev.cmux.ios.test", + osVersion: "iOS test", + deviceModel: "test" + ) + } + ) + + store.signIn() + let slowTask = Task { @MainActor in + await store.connectPairingURLResult(try attachURL(for: slowTicket).absoluteString) + } + await router.waitForFirstWorkspaceListRequest() + + let warningResult = await store.connectPairingURLResult( + "cmux-ios://attach?v=2&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + ) + await router.releaseFirstWorkspaceListResponse() + let slowResult = await slowTask.value + + #expect(warningResult == .needsUserApproval) + #expect(slowResult == .superseded) + #expect(store.connectionState == .disconnected) + #expect(store.activeTicket == nil) + #expect(store.pairingVersionWarning != nil) +} + @MainActor @Test func attachURLWithoutPathStillConnects() async throws { let route = try CmxAttachRoute( From 2339bd2a25e68f400ac8b2c7c66ff2a4f789ca17 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:50:22 -0700 Subject: [PATCH 07/31] Split pairing attempt generation from live connection --- .../MobileShellComposite.swift | 22 ++++++++++++++++--- .../cmuxFeatureTests/cmuxFeatureTests.swift | 3 ++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 10f603af98e..036f8e0308b 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -511,6 +511,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private var createWorkspaceTaskID: UUID? private var createTerminalTaskID: UUID? private var connectionGeneration: UUID + private var connectionAttemptGeneration: UUID private var reportedViewportSizesByTerminalKey: [MobileTerminalViewportKey: MobileTerminalViewportSize] private var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64] private var pendingTerminalByteEndSeqBySurfaceID: [String: UInt64] @@ -627,6 +628,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.createWorkspaceTaskID = nil self.createTerminalTaskID = nil self.connectionGeneration = UUID() + self.connectionAttemptGeneration = UUID() self.reportedViewportSizesByTerminalKey = [:] self.deliveredTerminalByteEndSeqBySurfaceID = [:] self.pendingTerminalByteEndSeqBySurfaceID = [:] @@ -685,6 +687,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { suppressNextConnectionOutageEdge = true invalidatePairingAttempt() connectionGeneration = UUID() + connectionAttemptGeneration = UUID() isSignedIn = false connectionState = .disconnected macConnectionStatus = .unavailable @@ -2016,6 +2019,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) async -> MobilePairingURLConnectionResult { let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) invalidatePairingAttempt() + connectionAttemptGeneration = UUID() + if connectionState != .connected { + clearActiveConnectionContext() + macConnectionStatus = .unavailable + replaceRemoteClient(with: nil) + } clearPairingError() clearPairingVersionWarning() let ticket: CmxAttachTicket @@ -2742,6 +2751,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { allowsStackAuthFallback: Bool? = nil ) async throws -> MobilePairingFailureCategory? { let generation = UUID() + connectionAttemptGeneration = generation connectionGeneration = generation diagnosticLog?.record(DiagnosticEvent(.connect)) cancelRemoteOperationTasks() @@ -2768,7 +2778,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { replaceRemoteClient(with: nil) guard let runtime else { - guard generation == connectionGeneration else { return nil } + guard isCurrentConnectionAttempt(generation) else { return nil } clearPairingError() applyPreviewTicket(ticket, route: firstRoute) connectionState = .connected @@ -2800,7 +2810,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { timeoutNanoseconds: runtime.pairingRequestTimeoutNanoseconds ) let response = try MobileSyncWorkspaceListResponse.decode(resultData) - guard generation == connectionGeneration, isSignedIn else { return nil } + guard isCurrentConnectionAttempt(generation) else { return nil } replaceRemoteClient(with: client) startTerminalRefreshPolling() // The connect seam guarantees identity recovery for an @@ -2832,7 +2842,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return nil } catch { lastError = error - guard generation == connectionGeneration, isSignedIn else { return nil } + guard isCurrentConnectionAttempt(generation) else { return nil } mobileShellLog.error( "pairing route failed kind=\(route.kind.rawValue, privacy: .public) endpoint=\(route.endpoint.logDescription, privacy: .private) scoped=\(workspaceListRequest.isScoped ? 1 : 0, privacy: .public): \(String(describing: error), privacy: .private)" ) @@ -2982,6 +2992,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private func clearRemoteConnectionContext() { connectionGeneration = UUID() + connectionAttemptGeneration = UUID() cancelRemoteOperationTasks() clearActiveConnectionContext() macConnectionStatus = .unavailable @@ -3035,6 +3046,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let attemptID = UUID() pairingAttemptID = attemptID connectionGeneration = UUID() + connectionAttemptGeneration = UUID() cancelRemoteOperationTasks() rawTerminalInputBuffer.clear() clearPairingError() @@ -3102,6 +3114,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairingAttemptID == attemptID && isSignedIn } + private func isCurrentConnectionAttempt(_ generation: UUID) -> Bool { + generation == connectionAttemptGeneration && isSignedIn + } + /// Invalidate the in-flight attempt outside ``beginPairingAttempt(method:)`` /// (cancel, sign-out, live-connection teardown), dropping its instrumentation /// so a stale attempt can never emit `ios_pairing_*` via a later auth eviction. diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 2c393563e23..8ab81bd1e80 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -448,8 +448,9 @@ final class TerminalOutputCollector { ) store.signIn() + let slowURL = try attachURL(for: slowTicket).absoluteString let slowTask = Task { @MainActor in - await store.connectPairingURLResult(try attachURL(for: slowTicket).absoluteString) + await store.connectPairingURLResult(slowURL) } await router.waitForFirstWorkspaceListRequest() From a42dc7d092cac2c6f4b222af755c1103c77d6416 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:54:29 -0700 Subject: [PATCH 08/31] Defer attach URL until auth restore completes --- .../CMUXMobileRootView.swift | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index f005c3b43c1..39a04e3b8e6 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -130,17 +130,15 @@ struct CMUXMobileRootView: View { guard isAuthenticated else { return } - if let rawURL = pendingAttachURL { - pendingAttachURL = nil - Task { - await store.connectPairingURL(rawURL) - } + if consumePendingURLIfReady() { return } reconnectStoredMacIfNeeded() } .onChange(of: authManager.isRestoringSession) { _, isRestoringSession in syncShellAuthentication(isAuthenticated, isRestoringSession: isRestoringSession) + guard !isRestoringSession else { return } + _ = consumePendingURLIfReady() } .onChange(of: store.connectionState) { _, connectionState in if connectionState == .connected { @@ -356,6 +354,10 @@ struct CMUXMobileRootView: View { } private func connectAttachURL(_ rawURL: String) { + guard !authManager.isRestoringSession else { + pendingAttachURL = rawURL + return + } didAuthenticateWithAttachTicket = true syncShellAuthentication(true) Task { @@ -367,6 +369,28 @@ struct CMUXMobileRootView: View { } } + @discardableResult + private func consumePendingURLIfReady() -> Bool { + guard let rawURL = pendingAttachURL else { return false } + if isRawAttachURL(rawURL) { + guard !authManager.isRestoringSession else { return false } + pendingAttachURL = nil + connectAttachURL(rawURL) + return true + } + guard isAuthenticated else { return false } + pendingAttachURL = nil + Task { + await store.connectPairingURL(rawURL) + } + return true + } + + private func isRawAttachURL(_ rawURL: String) -> Bool { + guard let url = URL(string: rawURL) else { return false } + return MobileRootAuthGate.isAttachURL(url) + } + private func cancelPairing() { store.cancelPairing() clearAttachTicketAuthenticationIfNeeded() From 5cb9b73f49b307e6967b2b095c5ea58485444dac Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:59:57 -0700 Subject: [PATCH 09/31] Allow attach auth before email is available --- .../MobileShellComposite.swift | 5 ++-- .../cmuxFeatureTests/cmuxFeatureTests.swift | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 036f8e0308b..b01873263c1 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -3162,8 +3162,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { actualEmail: String? ) -> MobilePairingFailureCategory? { guard let expected = normalizedEmail(ticket.macUserEmail) else { return nil } - guard normalizedEmail(actualEmail) == expected else { - return .emailMismatch(expected: expected, actual: normalizedEmail(actualEmail)) + guard let actual = normalizedEmail(actualEmail) else { return nil } + guard actual == expected else { + return .emailMismatch(expected: expected, actual: actual) } return nil } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 8ab81bd1e80..3ddec2231ec 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -1551,6 +1551,35 @@ final class TerminalOutputCollector { #expect(try await responses.sentRequests().isEmpty) } +@MainActor +@Test func minimalPairingCodeWithUnknownPhoneEmailUsesHostAuth() async throws { + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "qr-workspace", title: "QR Workspace"), + ]) + let runtime = testRuntime( + supportedRouteKinds: [.tailscale], + transportFactory: ScriptedTransportFactory(responses: responses) + ) + let store = CMUXMobileShellStore( + runtime: runtime, + workspaces: PreviewMobileHost.workspaces, + identityProvider: TestIdentityProvider( + currentUserIDValue: nil, + currentUserEmailValue: nil + ) + ) + + store.signIn() + let result = await store.connectPairingURLResult( + "cmux-ios://attach?v=2&e=mac@example.com&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + ) + + #expect(result == .connected) + #expect(store.connectionState == .connected) + #expect(store.connectedHostName == "100.71.210.41") + #expect(try await responses.sentRequests().contains { $0.method == "workspace.list" }) +} + @MainActor @Test func minimalPairingCodeBuildMismatchWarnsAndContinuesAfterAcceptance() async throws { let responses = ScriptedTransportResponses([ From 5995237ff08a7ee8132f3c2912df709277c72771 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:06:10 -0700 Subject: [PATCH 10/31] Record QR validation pairing analytics --- .../CmuxMobileShell/MobileShellComposite.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index b01873263c1..3d79d090d81 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2018,7 +2018,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) - invalidatePairingAttempt() + _ = beginPairingValidationAttempt(method: "qr") connectionAttemptGeneration = UUID() if connectionState != .connected { clearActiveConnectionContext() @@ -3043,14 +3043,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// single `ios_pairing_started` fire-site. `method` is `qr`/`manual`/ /// `attach_url`; pass `nil` for non-instrumented internal flows (preview). private func beginPairingAttempt(method: String? = nil) -> UUID { - let attemptID = UUID() - pairingAttemptID = attemptID + let attemptID = beginPairingValidationAttempt(method: method) connectionGeneration = UUID() connectionAttemptGeneration = UUID() cancelRemoteOperationTasks() rawTerminalInputBuffer.clear() clearPairingError() clearPairingVersionWarning() + return attemptID + } + + private func beginPairingValidationAttempt(method: String? = nil) -> UUID { + let attemptID = UUID() + pairingAttemptID = attemptID if let method { pairingAttemptStartedAt = runtime?.now() ?? Date() pairingAttemptMethod = method From bbacd8cea94be2a1e45fa87f8325a3150478ebdc Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:20:03 -0700 Subject: [PATCH 11/31] Address mobile pairing review findings --- .../MobileShellComposite.swift | 49 +- cmuxTests/MobileHostAuthorizationTests.swift | 447 ++++++++---------- 2 files changed, 229 insertions(+), 267 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 3d79d090d81..9af051750f6 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2144,6 +2144,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { clearRemoteConnectionContext() } + /// Accepts the pending version mismatch warning and retries the stored pairing URL. + /// + /// Returns the retry result so the UI can clear temporary attach-ticket + /// authentication only after the accepted pairing flow reaches a terminal + /// state. @discardableResult public func acceptPairingVersionWarning() async -> MobilePairingURLConnectionResult { guard let rawURL = pendingPairingVersionWarningURL else { @@ -3166,8 +3171,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { for ticket: CmxAttachTicket, actualEmail: String? ) -> MobilePairingFailureCategory? { - guard let expected = normalizedEmail(ticket.macUserEmail) else { return nil } - guard let actual = normalizedEmail(actualEmail) else { return nil } + guard let expected = mobileShellNormalizedEmail(ticket.macUserEmail) else { return nil } + guard let actual = mobileShellNormalizedEmail(actualEmail) else { return nil } guard actual == expected else { return .emailMismatch(expected: expected, actual: actual) } @@ -3175,13 +3180,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } private func versionWarning(for ticket: CmxAttachTicket) -> String? { - guard let macVersion = Self.normalizedNonEmpty(ticket.macAppVersion) else { return nil } + guard let macVersion = mobileShellNormalizedNonEmpty(ticket.macAppVersion) else { return nil } let phoneStamp = feedbackStampProvider() - guard let phoneVersion = Self.normalizedNonEmpty(phoneStamp.appVersion) else { + guard let phoneVersion = mobileShellNormalizedNonEmpty(phoneStamp.appVersion) else { return nil } - let phoneBuild = Self.normalizedNonEmpty(phoneStamp.appBuild) - let macBuild = Self.normalizedNonEmpty(ticket.macAppBuild) + let phoneBuild = mobileShellNormalizedNonEmpty(phoneStamp.appBuild) + let macBuild = mobileShellNormalizedNonEmpty(ticket.macAppBuild) let versionDiffers = phoneVersion != macVersion let buildDiffers = phoneBuild != nil && macBuild != nil && phoneBuild != macBuild guard versionDiffers || buildDiffers else { return nil } @@ -3191,25 +3196,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) return String( format: format, - Self.versionDisplay(version: phoneVersion, build: phoneStamp.appBuild), - Self.versionDisplay(version: macVersion, build: ticket.macAppBuild) + mobileShellVersionDisplay(version: phoneVersion, build: phoneStamp.appBuild), + mobileShellVersionDisplay(version: macVersion, build: ticket.macAppBuild) ) } - private static func versionDisplay(version: String, build: String?) -> String { - guard let build = normalizedNonEmpty(build) else { return version } - return "\(version) (\(build))" - } - - private static func normalizedEmail(_ value: String?) -> String? { - normalizedNonEmpty(value)?.lowercased() - } - - private static func normalizedNonEmpty(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed?.isEmpty == false ? trimmed : nil - } - /// Record an `ios_pairing_failed` for a `connect()` that returned without /// connecting and already set a specific ``connectionError``: emits the reason /// `connect()` reported (fallback `other`) without overwriting the message. @@ -4851,6 +4842,20 @@ private struct MobileManualAttachTicketCreateResponse: Decodable, Sendable { } } +private func mobileShellVersionDisplay(version: String, build: String?) -> String { + guard let build = mobileShellNormalizedNonEmpty(build) else { return version } + return "\(version) (\(build))" +} + +private func mobileShellNormalizedEmail(_ value: String?) -> String? { + mobileShellNormalizedNonEmpty(value)?.lowercased() +} + +private func mobileShellNormalizedNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil +} + private extension CmxAttachTicket { func constrainingRoutes( to routes: [CmxAttachRoute], diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index e0010c570d8..6f9a8381e64 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -1,16 +1,17 @@ import CMUXMobileCore import Foundation @preconcurrency import Network -import XCTest +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) @testable import cmux #endif +@Suite(.serialized) @MainActor -final class MobileHostAuthorizationTests: XCTestCase { - func testAttachTicketStoreKeepsMultipleTicketsForSameTerminal() throws { +struct MobileHostAuthorizationTests { + @Test func testAttachTicketStoreKeepsMultipleTicketsForSameTerminal() throws { let store = MobileAttachTicketStore() let route = try CmxAttachRoute( id: "debug", @@ -34,18 +35,11 @@ final class MobileHostAuthorizationTests: XCTestCase { now: now.addingTimeInterval(1) ) - XCTAssertNotEqual(first.authToken, second.authToken) - XCTAssertEqual( - store.validTicket(authToken: first.authToken, now: now.addingTimeInterval(2))?.authToken, - first.authToken - ) - XCTAssertEqual( - store.validTicket(authToken: second.authToken, now: now.addingTimeInterval(2))?.authToken, - second.authToken - ) + #expect(first.authToken != second.authToken) + #expect(store.validTicket(authToken: first.authToken, now: now.addingTimeInterval(2))?.authToken == first.authToken) + #expect(store.validTicket(authToken: second.authToken, now: now.addingTimeInterval(2))?.authToken == second.authToken) } - - func testAttachTicketStoreRecordsCreatedResourceScopes() throws { + @Test func testAttachTicketStoreRecordsCreatedResourceScopes() throws { let store = MobileAttachTicketStore() let route = try CmxAttachRoute( id: "debug", @@ -65,12 +59,11 @@ final class MobileHostAuthorizationTests: XCTestCase { terminalID: "created-terminal" ) - let authorization = try XCTUnwrap(store.validAuthorization(authToken: ticket.authToken)) - XCTAssertEqual(authorization.createdWorkspaceIDs, Set(["created-workspace"])) - XCTAssertEqual(authorization.createdTerminalIDs, Set(["created-terminal"])) + let authorization = try #require(store.validAuthorization(authToken: ticket.authToken)) + #expect(authorization.createdWorkspaceIDs == Set(["created-workspace"])) + #expect(authorization.createdTerminalIDs == Set(["created-terminal"])) } - - func testMobileWorkspaceRPCRequiresAuthorization() async { + @Test func testMobileWorkspaceRPCRequiresAuthorization() async { let request = MobileHostRPCRequest( id: "workspace-list", method: "workspace.list", @@ -81,12 +74,11 @@ final class MobileHostAuthorizationTests: XCTestCase { let result = await MobileHostService.shared.debugAuthorizationError(for: request) guard case let .failure(error) = result else { - return XCTFail("workspace.list should require mobile authorization") + return #expect(Bool(false), "workspace.list should require mobile authorization") } - XCTAssertEqual(error.code, "unauthorized") + #expect(error.code == "unauthorized") } - - func testMobileHostStatusDoesNotRequireAuthorization() async { + @Test func testMobileHostStatusDoesNotRequireAuthorization() async { let request = MobileHostRPCRequest( id: "host-status", method: "mobile.host.status", @@ -96,27 +88,26 @@ final class MobileHostAuthorizationTests: XCTestCase { let result = await MobileHostService.shared.debugAuthorizationError(for: request) - XCTAssertNil(result) + #expect(result == nil) } #if DEBUG - func testDebugStackAuthTokenPolicyRequiresConfiguredToken() { - XCTAssertNil(MobileHostDevStackAuthPolicy.normalizedToken(" ")) - XCTAssertFalse(MobileHostDevStackAuthPolicy.authorize( + @Test func testDebugStackAuthTokenPolicyRequiresConfiguredToken() { + #expect(MobileHostDevStackAuthPolicy.normalizedToken(" ") == nil) + #expect(!MobileHostDevStackAuthPolicy.authorize( providedToken: "cmux-dev-token", acceptedToken: nil )) - XCTAssertFalse(MobileHostDevStackAuthPolicy.authorize( + #expect(!MobileHostDevStackAuthPolicy.authorize( providedToken: "cmux-dev-token", acceptedToken: "other-token" )) - XCTAssertTrue(MobileHostDevStackAuthPolicy.authorize( + #expect(MobileHostDevStackAuthPolicy.authorize( providedToken: " cmux-dev-token ", acceptedToken: "cmux-dev-token" )) } - - func testDebugConfiguredStackAuthTokenAuthorizesBroadWorkspaceList() async { + @Test func testDebugConfiguredStackAuthTokenAuthorizesBroadWorkspaceList() async { let service = MobileHostService.shared service.debugConfigureAcceptedStackAuthTokenForTesting("cmux-dev-token") defer { @@ -135,46 +126,42 @@ final class MobileHostAuthorizationTests: XCTestCase { let result = await service.debugAuthorizationError(for: request) - XCTAssertNil(result) + #expect(result == nil) } #endif - - func testMobileHostRPCRejectsInvalidParamsShape() { + @Test func testMobileHostRPCRejectsInvalidParamsShape() { let data = Data(#"{"id":"bad-params","method":"workspace.list","params":[]}"#.utf8) let result = MobileHostRPCEnvelope.decodeRequest(data) guard case let .failure(error) = result else { - return XCTFail("Invalid params shape should be rejected") + return #expect(Bool(false), "Invalid params shape should be rejected") } - XCTAssertEqual(error.code, "invalid_request") - XCTAssertEqual(error.message, "params must be an object") + #expect(error.code == "invalid_request") + #expect(error.message == "params must be an object") } - - func testMobileHostRPCRejectsInvalidAuthShape() { + @Test func testMobileHostRPCRejectsInvalidAuthShape() { let data = Data(#"{"id":"bad-auth","method":"workspace.list","auth":"token"}"#.utf8) let result = MobileHostRPCEnvelope.decodeRequest(data) guard case let .failure(error) = result else { - return XCTFail("Invalid auth shape should be rejected") + return #expect(Bool(false), "Invalid auth shape should be rejected") } - XCTAssertEqual(error.code, "invalid_request") - XCTAssertEqual(error.message, "auth must be an object") + #expect(error.code == "invalid_request") + #expect(error.message == "auth must be an object") } - - func testMobileHostRPCIgnoresRefreshTokenOnlyAuth() { + @Test func testMobileHostRPCIgnoresRefreshTokenOnlyAuth() { let data = Data(#"{"id":"refresh-only","method":"workspace.list","auth":{"stack_refresh_token":"secret"}}"#.utf8) let result = MobileHostRPCEnvelope.decodeRequest(data) guard case let .success(request) = result else { - return XCTFail("Refresh-token-only auth should decode as an unauthenticated request") + return #expect(Bool(false), "Refresh-token-only auth should decode as an unauthenticated request") } - XCTAssertNil(request.auth) + #expect(request.auth == nil) } - - func testMobileRouteResolverPrefersTailscaleMagicDNSBeforeIPv4Fallback() throws { + @Test func testMobileRouteResolverPrefersTailscaleMagicDNSBeforeIPv4Fallback() throws { let resolver = MobileRouteResolver() let snapshot = resolver.routes( @@ -186,24 +173,23 @@ final class MobileHostAuthorizationTests: XCTestCase { ) let tailscaleRoutes = snapshot.routes.filter { $0.kind == .tailscale } - XCTAssertEqual(tailscaleRoutes.count, 2) - XCTAssertEqual(tailscaleRoutes.first?.priority, 10) - XCTAssertEqual(tailscaleRoutes.last?.priority, 20) + #expect(tailscaleRoutes.count == 2) + #expect(tailscaleRoutes.first?.priority == 10) + #expect(tailscaleRoutes.last?.priority == 20) if case let .hostPort(host, port) = tailscaleRoutes.first?.endpoint { - XCTAssertEqual(host, "work-mac.tailnet.ts.net") - XCTAssertEqual(port, 61234) + #expect(host == "work-mac.tailnet.ts.net") + #expect(port == 61234) } else { - XCTFail("Expected first Tailscale route to use a host/port endpoint") + #expect(Bool(false), "Expected first Tailscale route to use a host/port endpoint") } if case let .hostPort(host, port) = tailscaleRoutes.last?.endpoint { - XCTAssertEqual(host, "100.71.210.41") - XCTAssertEqual(port, 61234) + #expect(host == "100.71.210.41") + #expect(port == 61234) } else { - XCTFail("Expected fallback Tailscale route to use a host/port endpoint") + #expect(Bool(false), "Expected fallback Tailscale route to use a host/port endpoint") } } - - func testMobileRouteResolverImmediateSnapshotUsesNumericTailscaleFallbackWithoutDNS() throws { + @Test func testMobileRouteResolverImmediateSnapshotUsesNumericTailscaleFallbackWithoutDNS() throws { let resolver = MobileRouteResolver() let snapshot = resolver.routes( @@ -214,17 +200,16 @@ final class MobileHostAuthorizationTests: XCTestCase { ) let tailscaleRoutes = snapshot.routes.filter { $0.kind == .tailscale } - XCTAssertEqual(tailscaleRoutes.count, 1) + #expect(tailscaleRoutes.count == 1) if case let .hostPort(host, port) = tailscaleRoutes.first?.endpoint { - XCTAssertEqual(host, "100.71.210.41") - XCTAssertEqual(port, 61234) + #expect(host == "100.71.210.41") + #expect(port == 61234) } else { - XCTFail("Expected immediate snapshot to include a numeric Tailscale route") + #expect(Bool(false), "Expected immediate snapshot to include a numeric Tailscale route") } - XCTAssertEqual(snapshot.routes.filter { $0.kind == .debugLoopback }.count, 1) + #expect(snapshot.routes.filter { $0.kind == .debugLoopback }.count == 1) } - - func testMobileRouteResolverAwaitsMagicDNSForPublicStatusRoutes() async throws { + @Test func testMobileRouteResolverAwaitsMagicDNSForPublicStatusRoutes() async throws { let resolver = MobileRouteResolver() let snapshot = await resolver.routesResolvingTailscaleDNS( @@ -238,16 +223,15 @@ final class MobileHostAuthorizationTests: XCTestCase { ) let tailscaleRoutes = snapshot.routes.filter { $0.kind == .tailscale } - XCTAssertEqual(tailscaleRoutes.count, 2) + #expect(tailscaleRoutes.count == 2) if case let .hostPort(host, port) = tailscaleRoutes.first?.endpoint { - XCTAssertEqual(host, "work-mac.tailnet.ts.net") - XCTAssertEqual(port, 61234) + #expect(host == "work-mac.tailnet.ts.net") + #expect(port == 61234) } else { - XCTFail("Expected public status route to wait for MagicDNS") + #expect(Bool(false), "Expected public status route to wait for MagicDNS") } } - - func testMobileRouteResolverRefreshesStalePublicStatusRoutes() async throws { + @Test func testMobileRouteResolverRefreshesStalePublicStatusRoutes() async throws { let resolver = MobileRouteResolver() let now = Date() @@ -274,14 +258,13 @@ final class MobileHostAuthorizationTests: XCTestCase { let tailscaleRoutes = refreshed.routes.filter { $0.kind == .tailscale } if case let .hostPort(host, port) = tailscaleRoutes.first?.endpoint { - XCTAssertEqual(host, "new-mac.tailnet.ts.net") - XCTAssertEqual(port, 61234) + #expect(host == "new-mac.tailnet.ts.net") + #expect(port == 61234) } else { - XCTFail("Expected stale public status routes to refresh") + #expect(Bool(false), "Expected stale public status routes to refresh") } } - - func testMobileRouteResolverRetriesAfterIPOnlyPublicStatusRoutes() async throws { + @Test func testMobileRouteResolverRetriesAfterIPOnlyPublicStatusRoutes() async throws { let resolver = MobileRouteResolver() let now = Date() @@ -305,25 +288,22 @@ final class MobileHostAuthorizationTests: XCTestCase { let tailscaleRoutes = refreshed.routes.filter { $0.kind == .tailscale } if case let .hostPort(host, port) = tailscaleRoutes.first?.endpoint { - XCTAssertEqual(host, "work-mac.tailnet.ts.net") - XCTAssertEqual(port, 61234) + #expect(host == "work-mac.tailnet.ts.net") + #expect(port == 61234) } else { - XCTFail("Expected IP-only public status routes to retry MagicDNS resolution") + #expect(Bool(false), "Expected IP-only public status routes to retry MagicDNS resolution") } } - - func testMobileRouteResolverNotifiesCallbackForInFlightMagicDNSRefresh() async throws { + @Test func testMobileRouteResolverNotifiesCallbackForInFlightMagicDNSRefresh() async throws { let resolver = MobileRouteResolver() - let started = expectation(description: "refresh started") - let callback = expectation(description: "refresh callback") - let startedBox = SendableExpectation(started) - let callbackBox = SendableExpectation(callback) + let started = AsyncTestSignal() + let callback = AsyncTestSignal() let gate = SendableSemaphore(value: 0) let observedHosts = LockedHosts() resolver.refreshTailscaleRoutes( resolveHosts: { - startedBox.fulfill() + started.fulfill() gate.wait() return [ "work-mac.tailnet.ts.net", @@ -331,7 +311,7 @@ final class MobileHostAuthorizationTests: XCTestCase { ] } ) - await fulfillment(of: [started], timeout: 1) + await started.wait() resolver.refreshTailscaleRoutes( resolveHosts: { @@ -339,13 +319,13 @@ final class MobileHostAuthorizationTests: XCTestCase { }, onResolvedHosts: { hosts in observedHosts.set(hosts) - callbackBox.fulfill() + callback.fulfill() } ) gate.signal() - await fulfillment(of: [callback], timeout: 1) - XCTAssertEqual(observedHosts.value(), [ + await callback.wait() + #expect(observedHosts.value() == [ "work-mac.tailnet.ts.net", "100.71.210.41", ]) @@ -353,13 +333,12 @@ final class MobileHostAuthorizationTests: XCTestCase { let snapshot = resolver.routes(port: 61234, immediateHosts: { [] }) let tailscaleRoutes = snapshot.routes.filter { $0.kind == .tailscale } if case let .hostPort(host, _) = tailscaleRoutes.first?.endpoint { - XCTAssertEqual(host, "work-mac.tailnet.ts.net") + #expect(host == "work-mac.tailnet.ts.net") } else { - XCTFail("Expected callback refresh to populate the MagicDNS route") + #expect(Bool(false), "Expected callback refresh to populate the MagicDNS route") } } - - func testMobileAttachTicketCreateRequiresAuthorization() async { + @Test func testMobileAttachTicketCreateRequiresAuthorization() async { let request = MobileHostRPCRequest( id: "attach-ticket-create", method: "mobile.attach_ticket.create", @@ -370,12 +349,11 @@ final class MobileHostAuthorizationTests: XCTestCase { let result = await MobileHostService.shared.debugAuthorizationError(for: request) guard case let .failure(error) = result else { - return XCTFail("mobile.attach_ticket.create should require mobile authorization") + return #expect(Bool(false), "mobile.attach_ticket.create should require mobile authorization") } - XCTAssertEqual(error.code, "unauthorized") + #expect(error.code == "unauthorized") } - - func testScopedAttachTicketRejectsWorkspaceAliasIgnoredByHandlers() throws { + @Test func testScopedAttachTicketRejectsWorkspaceAliasIgnoredByHandlers() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) let request = MobileHostRPCRequest( id: "workspace-list", @@ -389,10 +367,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertEqual(error?.code, "forbidden") + #expect(error?.code == "forbidden") } - - func testScopedAttachTicketRejectsTerminalAliasIgnoredByHandlers() throws { + @Test func testScopedAttachTicketRejectsTerminalAliasIgnoredByHandlers() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "terminal-input", @@ -409,10 +386,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertEqual(error?.code, "forbidden") + #expect(error?.code == "forbidden") } - - func testAttachTicketAcceptsUnscopedWorkspaceListForPairedDevice() throws { + @Test func testAttachTicketAcceptsUnscopedWorkspaceListForPairedDevice() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "workspace-list", @@ -426,10 +402,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertNil(error) + #expect(error == nil) } - - func testTerminalScopedAttachTicketAcceptsScopedWorkspaceList() throws { + @Test func testTerminalScopedAttachTicketAcceptsScopedWorkspaceList() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "workspace-list", @@ -446,10 +421,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertNil(error) + #expect(error == nil) } - - func testAttachTicketAcceptsTerminalCreateForPairedDevice() throws { + @Test func testAttachTicketAcceptsTerminalCreateForPairedDevice() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "terminal-create", @@ -465,10 +439,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertNil(error) + #expect(error == nil) } - - func testAttachTicketAcceptsWorkspaceCreateForPairedDevice() throws { + @Test func testAttachTicketAcceptsWorkspaceCreateForPairedDevice() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "workspace-create", @@ -482,10 +455,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertNil(error) + #expect(error == nil) } - - func testAttachTicketAcceptsReplayForCreatedWorkspace() throws { + @Test func testAttachTicketAcceptsReplayForCreatedWorkspace() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "terminal-replay", @@ -506,10 +478,9 @@ final class MobileHostAuthorizationTests: XCTestCase { createdWorkspaceIDs: ["created-workspace"] ) - XCTAssertNil(error) + #expect(error == nil) } - - func testAttachTicketAcceptsReplayForCreatedTerminal() throws { + @Test func testAttachTicketAcceptsReplayForCreatedTerminal() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "terminal-replay", @@ -530,10 +501,9 @@ final class MobileHostAuthorizationTests: XCTestCase { createdTerminalIDs: ["created-terminal"] ) - XCTAssertNil(error) + #expect(error == nil) } - - func testWorkspaceScopedAttachTicketAcceptsTerminalCreate() throws { + @Test func testWorkspaceScopedAttachTicketAcceptsTerminalCreate() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) let request = MobileHostRPCRequest( id: "terminal-create", @@ -547,10 +517,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertNil(error) + #expect(error == nil) } - - func testTerminalScopedAttachTicketRejectsConflictingTerminalAliases() throws { + @Test func testTerminalScopedAttachTicketRejectsConflictingTerminalAliases() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal-a") let request = MobileHostRPCRequest( id: "workspace-list", @@ -568,10 +537,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertEqual(error?.code, "forbidden") + #expect(error?.code == "forbidden") } - - func testScopedAttachTicketAcceptsHandlerParameterNames() throws { + @Test func testScopedAttachTicketAcceptsHandlerParameterNames() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "terminal-input", @@ -588,10 +556,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertNil(error) + #expect(error == nil) } - - func testScopedAttachTicketAcceptsNamedTerminalReplay() throws { + @Test func testScopedAttachTicketAcceptsNamedTerminalReplay() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "terminal-replay", @@ -608,10 +575,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertNil(error) + #expect(error == nil) } - - func testTerminalScopedAttachTicketRejectsDifferentTerminalInput() throws { + @Test func testTerminalScopedAttachTicketRejectsDifferentTerminalInput() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "terminal-input", @@ -629,10 +595,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertEqual(error?.code, "forbidden") + #expect(error?.code == "forbidden") } - - func testTerminalScopedAttachTicketRejectsUnscopedTerminalReplay() throws { + @Test func testTerminalScopedAttachTicketRejectsUnscopedTerminalReplay() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( id: "terminal-replay", @@ -646,10 +611,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertEqual(error?.code, "forbidden") + #expect(error?.code == "forbidden") } - - func testWorkspaceScopedAttachTicketRejectsTerminalReplayOutsideWorkspace() throws { + @Test func testWorkspaceScopedAttachTicketRejectsTerminalReplayOutsideWorkspace() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) let request = MobileHostRPCRequest( id: "terminal-replay", @@ -666,10 +630,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertEqual(error?.code, "forbidden") + #expect(error?.code == "forbidden") } - - func testWorkspaceScopedAttachTicketAcceptsTerminalReplayInWorkspace() throws { + @Test func testWorkspaceScopedAttachTicketAcceptsTerminalReplayInWorkspace() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) let request = MobileHostRPCRequest( id: "terminal-replay", @@ -686,10 +649,9 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertNil(error) + #expect(error == nil) } - - func testMacScopedAttachTicketAcceptsTerminalReplayInAnyWorkspace() throws { + @Test func testMacScopedAttachTicketAcceptsTerminalReplayInAnyWorkspace() throws { let ticket = try scopedAttachTicket(workspaceID: "", terminalID: nil) let request = MobileHostRPCRequest( id: "terminal-replay", @@ -706,49 +668,43 @@ final class MobileHostAuthorizationTests: XCTestCase { let error = MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - XCTAssertNil(error) + #expect(error == nil) } - - func testStackUserIDAuthorizationRequiresSignedInMacUser() throws { - XCTAssertThrowsError( + @Test func testStackUserIDAuthorizationRequiresSignedInMacUser() throws { + #expect(throws: (any Error).self) { try MobileHostAuthorizationPolicy.authorizeStackUserID( localUserID: nil, remoteUserID: "user_123" ) - ) + } } - - func testStackUserIDAuthorizationRequiresMatchingUserID() throws { - XCTAssertThrowsError( + @Test func testStackUserIDAuthorizationRequiresMatchingUserID() throws { + #expect(throws: (any Error).self) { try MobileHostAuthorizationPolicy.authorizeStackUserID( localUserID: "user_local", remoteUserID: "user_remote" ) - ) + } - XCTAssertNoThrow( - try MobileHostAuthorizationPolicy.authorizeStackUserID( - localUserID: " user_123 ", - remoteUserID: "user_123" - ) + try MobileHostAuthorizationPolicy.authorizeStackUserID( + localUserID: " user_123 ", + remoteUserID: "user_123" ) } - - func testMobileHostConnectionCloseOnlyClearsConnectionTracking() { + @Test func testMobileHostConnectionCloseOnlyClearsConnectionTracking() { let service = MobileHostService.shared let connectionID = UUID() service.debugResetMobileLifecycleStateForTesting() service.debugRecordClientIDForTesting("ios-client", connectionID: connectionID) - XCTAssertEqual(service.debugTrackedClientIDsForTesting(connectionID: connectionID), Set(["ios-client"])) + #expect(service.debugTrackedClientIDsForTesting(connectionID: connectionID) == Set(["ios-client"])) service.debugRemoveConnectionForTesting(id: connectionID) - XCTAssertNil(service.debugTrackedClientIDsForTesting(connectionID: connectionID)) + #expect(service.debugTrackedClientIDsForTesting(connectionID: connectionID) == nil) } - - func testIdleMobileConnectionDoesNotKeepRequestActivityBusy() { + @Test func testIdleMobileConnectionDoesNotKeepRequestActivityBusy() { MobileHostRequestActivity.resetForTesting() MobileHostRequestActivity.beginConnection() defer { @@ -756,12 +712,11 @@ final class MobileHostAuthorizationTests: XCTestCase { MobileHostRequestActivity.resetForTesting() } - XCTAssertFalse(MobileHostRequestActivity.hasActiveRequest) - XCTAssertFalse(MobileHostRequestActivity.hasRecentActivity(within: 60)) - XCTAssertEqual(MobileHostRequestActivity.quietDelay(for: 60), 0) + #expect(!MobileHostRequestActivity.hasActiveRequest) + #expect(!MobileHostRequestActivity.hasRecentActivity(within: 60)) + #expect(MobileHostRequestActivity.quietDelay(for: 60) == 0) } - - func testMobileHostConnectionCloseLeavesViewportReportsForPollingClient() { + @Test func testMobileHostConnectionCloseClearsOnlyClosedClientViewportReports() { let service = MobileHostService.shared let terminalController = TerminalController.shared let connectionID = UUID() @@ -785,16 +740,14 @@ final class MobileHostAuthorizationTests: XCTestCase { service.debugRemoveConnectionForTesting(id: connectionID) - XCTAssertEqual( - terminalController.debugMobileViewportReportClientIDsForTesting(surfaceID: surfaceID), - Set(["ios-client", "ipad-client"]), - "Mobile RPC connections are short lived, so socket close must not clear viewport reports before their TTL expires." + #expect( + terminalController.debugMobileViewportReportClientIDsForTesting(surfaceID: surfaceID) == Set(["ipad-client"]), + "Closing one mobile RPC connection should clear only that connection's viewport reports." ) terminalController.debugResetMobileViewportReportsForTesting() } - - func testMobileHostIgnoresStaleListenerStateCallbacks() { + @Test func testMobileHostIgnoresStaleListenerStateCallbacks() { let service = MobileHostService.shared let currentGeneration = UUID() let staleGeneration = UUID() @@ -811,18 +764,17 @@ final class MobileHostAuthorizationTests: XCTestCase { generation: staleGeneration ) - XCTAssertEqual(service.debugListenerGenerationForTesting(), currentGeneration) - XCTAssertTrue(service.debugListenerUsesEphemeralFallbackForTesting()) - XCTAssertEqual(service.debugListenerPortForTesting(), 61234) + #expect(service.debugListenerGenerationForTesting() == currentGeneration) + #expect(service.debugListenerUsesEphemeralFallbackForTesting()) + #expect(service.debugListenerPortForTesting() == 61234) service.debugHandleListenerStateForTesting(.cancelled, generation: staleGeneration) - XCTAssertEqual(service.debugListenerGenerationForTesting(), currentGeneration) - XCTAssertTrue(service.debugListenerUsesEphemeralFallbackForTesting()) - XCTAssertEqual(service.debugListenerPortForTesting(), 61234) + #expect(service.debugListenerGenerationForTesting() == currentGeneration) + #expect(service.debugListenerUsesEphemeralFallbackForTesting()) + #expect(service.debugListenerPortForTesting() == 61234) } - - func testMobileHostWaitingListenerDoesNotPublishRoutes() { + @Test func testMobileHostWaitingListenerDoesNotPublishRoutes() { let service = MobileHostService.shared let generation = UUID() @@ -837,13 +789,12 @@ final class MobileHostAuthorizationTests: XCTestCase { service.debugHandleListenerStateForTesting(.waiting(.posix(.EADDRINUSE)), generation: generation) let status = service.statusSnapshot() - XCTAssertFalse(status.isRunning) - XCTAssertNil(status.port) - XCTAssertTrue(status.routes.isEmpty) - XCTAssertNil(service.debugListenerPortForTesting()) + #expect(!status.isRunning) + #expect(status.port == nil) + #expect(status.routes.isEmpty) + #expect(service.debugListenerPortForTesting() == nil) } - - func testMobileHostConnectionClosesWhenFirstFrameTimesOut() async throws { + @Test func testMobileHostConnectionClosesWhenFirstFrameTimesOut() async throws { let connectionID = UUID() let recorder = MobileHostConnectionCloseRecorder() let connection = NWConnection( @@ -874,10 +825,9 @@ final class MobileHostAuthorizationTests: XCTestCase { } let finalRecordedIDs = await recorder.recordedIDs() - XCTAssertEqual(finalRecordedIDs, [connectionID]) + #expect(finalRecordedIDs == [connectionID]) } - - func testMobileHostConnectionClosesWhenIdleAfterFirstFrame() async throws { + @Test func testMobileHostConnectionClosesWhenIdleAfterFirstFrame() async throws { let connectionID = UUID() let recorder = MobileHostConnectionCloseRecorder() let connection = NWConnection( @@ -908,10 +858,9 @@ final class MobileHostAuthorizationTests: XCTestCase { } let finalRecordedIDs = await recorder.recordedIDs() - XCTAssertEqual(finalRecordedIDs, [connectionID]) + #expect(finalRecordedIDs == [connectionID]) } - - func testMobileHostConnectionKeepsSubscribedEventStreamPastIdleTimeout() async throws { + @Test func testMobileHostConnectionKeepsSubscribedEventStreamPastIdleTimeout() async throws { let connectionID = UUID() let recorder = MobileHostConnectionCloseRecorder() let connection = NWConnection( @@ -935,7 +884,7 @@ final class MobileHostAuthorizationTests: XCTestCase { await session.debugStartIdleTimeoutAfterFrameForTesting() try await Task.sleep(nanoseconds: 25_000_000) let subscribedCloseIDs = await recorder.recordedIDs() - XCTAssertTrue(subscribedCloseIDs.isEmpty) + #expect(subscribedCloseIDs.isEmpty) _ = await session.unsubscribe(streamID: "events") for _ in 0..<100 { @@ -947,10 +896,9 @@ final class MobileHostAuthorizationTests: XCTestCase { } let finalRecordedIDs = await recorder.recordedIDs() - XCTAssertEqual(finalRecordedIDs, [connectionID]) + #expect(finalRecordedIDs == [connectionID]) } - - func testTerminalRenderObserverRetainsGhosttyDemandOnlyWithTerminalSubscriber() async throws { + @Test func testTerminalRenderObserverRetainsGhosttyDemandOnlyWithTerminalSubscriber() async throws { let service = MobileHostService.shared service.debugResetMobileLifecycleStateForTesting() let observer = MobileTerminalRenderObserver.shared @@ -961,9 +909,9 @@ final class MobileHostAuthorizationTests: XCTestCase { service.debugResetMobileLifecycleStateForTesting() } - drainMobileHostMainQueue() - XCTAssertFalse(MobileHostService.debugHasEventSubscribersForTesting(topic: "terminal.updated")) - XCTAssertFalse(observer.debugIsRetainingNotificationDemandForTesting) + await drainMobileHostMainQueue() + #expect(!MobileHostService.debugHasEventSubscribersForTesting(topic: "terminal.updated")) + #expect(!observer.debugIsRetainingNotificationDemandForTesting) let session = MobileHostConnection( id: UUID(), @@ -979,19 +927,18 @@ final class MobileHostAuthorizationTests: XCTestCase { ) await session.subscribe(streamID: "events", topics: ["terminal.updated"]) - drainMobileHostMainQueue() + await drainMobileHostMainQueue() - XCTAssertTrue(MobileHostService.debugHasEventSubscribersForTesting(topic: "terminal.updated")) - XCTAssertTrue(observer.debugIsRetainingNotificationDemandForTesting) + #expect(MobileHostService.debugHasEventSubscribersForTesting(topic: "terminal.updated")) + #expect(observer.debugIsRetainingNotificationDemandForTesting) _ = await session.unsubscribe(streamID: "events") - drainMobileHostMainQueue() + await drainMobileHostMainQueue() - XCTAssertFalse(MobileHostService.debugHasEventSubscribersForTesting(topic: "terminal.updated")) - XCTAssertFalse(observer.debugIsRetainingNotificationDemandForTesting) + #expect(!MobileHostService.debugHasEventSubscribersForTesting(topic: "terminal.updated")) + #expect(!observer.debugIsRetainingNotificationDemandForTesting) } - - func testMobileWorkspaceListHashIncludesDisplayedDirectories() { + @Test func testMobileWorkspaceListHashIncludesDisplayedDirectories() { let workspace = Workspace( title: "Mobile", workingDirectory: "/tmp/mobile-a", @@ -1008,7 +955,7 @@ final class MobileHostAuthorizationTests: XCTestCase { selectedTabID: workspace.id ) - XCTAssertNotEqual(initial, afterWorkspaceDirectory) + #expect(initial != afterWorkspaceDirectory) workspace.panelDirectories[UUID()] = "/tmp/mobile-terminal" let afterTerminalDirectory = MobileWorkspaceListObserver.summaryHashForTesting( @@ -1016,10 +963,9 @@ final class MobileHostAuthorizationTests: XCTestCase { selectedTabID: workspace.id ) - XCTAssertNotEqual(afterWorkspaceDirectory, afterTerminalDirectory) + #expect(afterWorkspaceDirectory != afterTerminalDirectory) } - - func testMobileHostConnectionDoesNotPersistUnauthorizedEventSubscription() async throws { + @Test func testMobileHostConnectionDoesNotPersistUnauthorizedEventSubscription() async throws { let connectionID = UUID() let recorder = MobileHostConnectionCloseRecorder() let socket = try MobileHostStartedTestSocket() @@ -1053,10 +999,9 @@ final class MobileHostAuthorizationTests: XCTestCase { } let finalRecordedIDs = await recorder.recordedIDs() - XCTAssertEqual(finalRecordedIDs, [connectionID]) + #expect(finalRecordedIDs == [connectionID]) } - - func testMobileHostConnectionStopsBatchedFrameProcessingAfterClose() async throws { + @Test func testMobileHostConnectionStopsBatchedFrameProcessingAfterClose() async throws { let connectionID = UUID() let requestRecorder = MobileHostConnectionRequestRecorder() let sessionBox = MobileHostConnectionBox() @@ -1106,24 +1051,22 @@ final class MobileHostAuthorizationTests: XCTestCase { } try await Task.sleep(nanoseconds: 150_000_000) let recordedMethods = await requestRecorder.recordedMethods() - XCTAssertEqual(recordedMethods, ["workspace.list"]) + #expect(recordedMethods == ["workspace.list"]) } // MARK: - Advertised mobile host capabilities - - func testMobileHostAdvertisesWorkspaceActionCapabilities() { + @Test func testMobileHostAdvertisesWorkspaceActionCapabilities() { let capabilities = MobileHostService.mobileHostCapabilities - XCTAssertTrue(capabilities.contains("workspace.actions.v1")) - XCTAssertTrue(capabilities.contains("workspace.read_state.v1")) - XCTAssertTrue(capabilities.contains("workspace.close.v1")) - XCTAssertTrue(capabilities.contains("terminal.render_grid.v1")) + #expect(capabilities.contains("workspace.actions.v1")) + #expect(capabilities.contains("workspace.read_state.v1")) + #expect(capabilities.contains("workspace.close.v1")) + #expect(capabilities.contains("terminal.render_grid.v1")) } // MARK: - Mobile workspace.action sub-action gate - - func testMobileWorkspaceActionGateAllowsOnlyPinNameAndReadStateActions() { + @Test func testMobileWorkspaceActionGateAllowsOnlyPinNameAndReadStateActions() { for action in ["pin", "unpin", "rename", "mark_read", "mark_unread", "PIN", "UnPin", "RENAME", "MARK_READ", "Mark_Unread"] { - XCTAssertTrue( + #expect( TerminalController.mobileAllowsWorkspaceAction(action), "mobile workspace.action '\(action)' should be allowed" ) @@ -1134,12 +1077,12 @@ final class MobileHostAuthorizationTests: XCTestCase { "set_color", "clear_color", "set_description", "clear_description", "clear_name", "close", "self_destruct", "", ] { - XCTAssertFalse( - TerminalController.mobileAllowsWorkspaceAction(action), + #expect( + !TerminalController.mobileAllowsWorkspaceAction(action), "mobile workspace.action '\(action)' must be rejected" ) } - XCTAssertFalse(TerminalController.mobileAllowsWorkspaceAction(nil)) + #expect(!TerminalController.mobileAllowsWorkspaceAction(nil)) } private func scopedAttachTicket(workspaceID: String, terminalID: String?) throws -> CmxAttachTicket { @@ -1159,12 +1102,8 @@ final class MobileHostAuthorizationTests: XCTestCase { ) } - private func drainMobileHostMainQueue() { - let expectation = XCTestExpectation(description: "drain mobile host main queue") - DispatchQueue.main.async { - expectation.fulfill() - } - XCTWaiter().wait(for: [expectation], timeout: 1) + private func drainMobileHostMainQueue() async { + await Task.yield() } } @@ -1266,15 +1205,33 @@ private actor MobileHostConnectionBox { } } -private final class SendableExpectation: @unchecked Sendable { - private let expectation: XCTestExpectation +private final class AsyncTestSignal: @unchecked Sendable { + private let lock = NSLock() + private var fulfilled = false + private var continuations: [CheckedContinuation] = [] - init(_ expectation: XCTestExpectation) { - self.expectation = expectation + func fulfill() { + lock.lock() + fulfilled = true + let continuations = continuations + self.continuations = [] + lock.unlock() + for continuation in continuations { + continuation.resume() + } } - func fulfill() { - expectation.fulfill() + func wait() async { + await withCheckedContinuation { continuation in + lock.lock() + if fulfilled { + lock.unlock() + continuation.resume() + return + } + continuations.append(continuation) + lock.unlock() + } } } From bb13811718fb8e30c0cb8ea8f55096eac16ff9f0 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:30:03 -0700 Subject: [PATCH 12/31] Protect pairing QR email and analytics --- .../CMUXMobileCore/CmxPairingQRCode.swift | 34 +++++++++++---- .../Sources/CMUXMobileCore/CmxTransport.swift | 8 ++++ .../CmxPairingQRCodeTests.swift | 10 +++-- .../CmxAttachTicketInputTests.swift | 6 ++- .../MobileShellComposite.swift | 21 ++++++++-- .../cmuxFeatureTests/cmuxFeatureTests.swift | 42 ++++++++++++++++--- 6 files changed, 100 insertions(+), 21 deletions(-) diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift index 6eaa14e61c4..081891bb93d 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift @@ -1,13 +1,15 @@ +import CryptoKit import Foundation -/// The minimal pairing-QR grammar: expected Mac email/build metadata plus plain -/// `host:port` routes in the URL query. +/// The minimal pairing-QR grammar: expected Mac account/build metadata plus +/// plain `host:port` routes in the URL query. /// -/// `cmux-ios://attach?v=2&e=&av=&ab=&r=:[&r=:...]` +/// `cmux-ios://attach?v=2&eb=&av=&ab=&r=:[&r=:...]` /// /// A pairing QR needs to tell the phone where to dial and which non-secret -/// account/build context to check before dialing. Everything else the earlier -/// grammars carried has a better channel or no reason to exist: +/// account/build context to check before dialing. The account value is a +/// one-way binding of the normalized email, 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. @@ -63,8 +65,8 @@ public struct CmxPairingQRCode: Sendable { return nil } var items: [String] = ["v=\(Self.version)"] - if let email = normalizedNonEmpty(ticket.macUserEmail) { - items.append("e=\(percentEncodeQueryValue(email))") + if let emailBinding = Self.emailBinding(for: ticket.macUserEmail) { + items.append("eb=\(percentEncodeQueryValue(emailBinding))") } if let version = normalizedNonEmpty(ticket.macAppVersion) { items.append("av=\(percentEncodeQueryValue(version))") @@ -89,6 +91,18 @@ public struct CmxPairingQRCode: Sendable { encodableRoutes(of: ticket) != nil } + /// Returns the public QR-safe binding for `email`, or `nil` when there is + /// no non-empty email to bind. The binding is deterministic so a signed-in + /// phone can check its own email before dialing, but the QR never exposes + /// the Mac account email in plain text. + public static func emailBinding(for email: String?) -> String? { + guard let normalized = normalizedEmail(email) else { return nil } + let input = Data("cmux-ios-pairing-email-v1\0\(normalized)".utf8) + return "sha256:" + SHA256.hash(data: input).map { byte in + String(format: "%02x", byte) + }.joined() + } + /// The route subsequence a v2 pairing URL would carry for `ticket`, or /// `nil` when the ticket is not expressible in the minimal grammar. /// @@ -183,6 +197,7 @@ public struct CmxPairingQRCode: Sendable { macDeviceID: "", macDisplayName: nil, macUserEmail: queryValue(named: "e", in: components), + macUserEmailBinding: queryValue(named: "eb", in: components), macAppVersion: queryValue(named: "av", in: components), macAppBuild: queryValue(named: "ab", in: components), routes: routes, @@ -267,6 +282,11 @@ private extension CmxPairingQRCode { return trimmed?.isEmpty == false ? trimmed : nil } + static func normalizedEmail(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed?.isEmpty == false ? trimmed : nil + } + func percentEncodeQueryValue(_ value: String) -> String { var allowed = CharacterSet.urlQueryAllowed allowed.remove(charactersIn: "&=+") diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift index 162c158d3da..a1eab549df9 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift @@ -176,6 +176,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { case macDeviceID case macDisplayName case macUserEmail + case macUserEmailBinding case macAppVersion case macAppBuild case routes @@ -200,6 +201,10 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { public let macDisplayName: String? /// The signed-in Mac account email the phone must match before pairing. public let macUserEmail: String? + /// A one-way normalized-email binding used by public pairing QR codes so + /// the phone can reject the wrong signed-in account without exposing the + /// Mac account email in the QR payload. + public let macUserEmailBinding: String? /// The Mac app's marketing version, used for warning-only compatibility checks. public let macAppVersion: String? /// The Mac app's build number, displayed with version mismatch warnings when present. @@ -222,6 +227,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { macDeviceID: container.decode(String.self, forKey: .macDeviceID), macDisplayName: container.decodeIfPresent(String.self, forKey: .macDisplayName), macUserEmail: container.decodeIfPresent(String.self, forKey: .macUserEmail), + macUserEmailBinding: container.decodeIfPresent(String.self, forKey: .macUserEmailBinding), macAppVersion: container.decodeIfPresent(String.self, forKey: .macAppVersion), macAppBuild: container.decodeIfPresent(String.self, forKey: .macAppBuild), routes: container.decode([CmxAttachRoute].self, forKey: .routes), @@ -252,6 +258,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { macDeviceID: String, macDisplayName: String?, macUserEmail: String? = nil, + macUserEmailBinding: String? = nil, macAppVersion: String? = nil, macAppBuild: String? = nil, routes: [CmxAttachRoute], @@ -264,6 +271,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { self.macDeviceID = macDeviceID self.macDisplayName = macDisplayName self.macUserEmail = macUserEmail + self.macUserEmailBinding = macUserEmailBinding self.macAppVersion = macAppVersion self.macAppBuild = macAppBuild self.routes = routes diff --git a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift index aa24d6ecd10..17dd2514694 100644 --- a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift +++ b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift @@ -72,7 +72,7 @@ import Testing #expect(decoded.routes.map(\.priority) == [10, 20]) } - @Test func roundTripsEmailAndBuildMetadata() throws { + @Test func roundTripsEmailBindingAndBuildMetadataWithoutExposingEmail() throws { let ticket = try CmxAttachTicket( workspaceID: "", terminalID: nil, @@ -89,12 +89,16 @@ import Testing ) let url = try #require(CmxPairingQRCode().encode(ticket)) - #expect(url.contains("e=Lawrence@Example.com")) + let binding = try #require(CmxPairingQRCode.emailBinding(for: "Lawrence@Example.com")) + #expect(url.contains("eb=\(binding)")) + #expect(!url.contains("Lawrence@Example.com")) + #expect(!url.lowercased().contains("lawrence@example.com")) #expect(url.contains("av=0.64.15")) #expect(url.contains("ab=42")) let decoded = try CmxPairingQRCode().decode(try components(url)) - #expect(decoded.macUserEmail == "Lawrence@Example.com") + #expect(decoded.macUserEmail == nil) + #expect(decoded.macUserEmailBinding == binding) #expect(decoded.macAppVersion == "0.64.15") #expect(decoded.macAppBuild == "42") #expect(decoded.routes == ticket.routes) diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift index 814693f0786..771e46e6812 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift @@ -127,15 +127,17 @@ import Testing // New-phone-scans-new-QR: the minimal v2 grammar (bare routes, no // payload blob) routes through the same input decoder as everything // else the scanner or a deep link can hand us. + let binding = try #require(CmxPairingQRCode.emailBinding(for: "user@example.com")) let decoded = try CmxAttachTicketInput.decode( - "cmux-ios://attach?v=2&e=user@example.com&av=0.64.15&ab=42&r=lawrences-mac.tail1234.ts.net:58465&r=100.64.0.5:58465" + "cmux-ios://attach?v=2&eb=\(binding)&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 == "user@example.com") + #expect(decoded.macUserEmail == nil) + #expect(decoded.macUserEmailBinding == binding) #expect(decoded.macAppVersion == "0.64.15") #expect(decoded.macAppBuild == "42") #expect(decoded.routes.count == 2) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 9af051750f6..2e2ebb29bd0 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1915,6 +1915,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { macDeviceID: reportedID, macDisplayName: ticket.macDisplayName, macUserEmail: ticket.macUserEmail, + macUserEmailBinding: ticket.macUserEmailBinding, macAppVersion: ticket.macAppVersion, macAppBuild: ticket.macAppBuild, routes: ticket.routes, @@ -2018,7 +2019,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) - _ = beginPairingValidationAttempt(method: "qr") + _ = beginPairingValidationAttempt() connectionAttemptGeneration = UUID() if connectionState != .connected { clearActiveConnectionContext() @@ -3171,10 +3172,18 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { for ticket: CmxAttachTicket, actualEmail: String? ) -> MobilePairingFailureCategory? { - guard let expected = mobileShellNormalizedEmail(ticket.macUserEmail) else { return nil } guard let actual = mobileShellNormalizedEmail(actualEmail) else { return nil } - guard actual == expected else { - return .emailMismatch(expected: expected, actual: actual) + if let expected = mobileShellNormalizedEmail(ticket.macUserEmail) { + guard actual == expected else { + return .emailMismatch(expected: expected, actual: actual) + } + return nil + } + guard let expectedBinding = mobileShellNormalizedNonEmpty(ticket.macUserEmailBinding) else { + return nil + } + guard CmxPairingQRCode.emailBinding(for: actual) == expectedBinding else { + return .authFailed } return nil } @@ -4866,6 +4875,10 @@ private extension CmxAttachTicket { terminalID: terminalID, macDeviceID: macDeviceID, macDisplayName: macDisplayName ?? fallbackDisplayName, + macUserEmail: macUserEmail, + macUserEmailBinding: macUserEmailBinding, + macAppVersion: macAppVersion, + macAppBuild: macAppBuild, routes: routes, expiresAt: expiresAt, authToken: authToken diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 3ddec2231ec..fba819313c0 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -1540,14 +1540,16 @@ final class TerminalOutputCollector { ) store.signIn() + let binding = try #require(CmxPairingQRCode.emailBinding(for: "mac@example.com")) let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&e=mac@example.com&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&eb=\(binding)&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .failed) #expect(store.connectionState == .disconnected) - #expect(store.connectionError?.contains("mac@example.com") == true) - #expect(store.connectionError?.contains("phone@example.com") == true) + #expect(store.connectionError?.contains("same email") == true) + #expect(store.connectionError?.contains("mac@example.com") == false) + #expect(store.connectionError?.contains("phone@example.com") == false) #expect(try await responses.sentRequests().isEmpty) } @@ -1570,8 +1572,9 @@ final class TerminalOutputCollector { ) store.signIn() + let binding = try #require(CmxPairingQRCode.emailBinding(for: "mac@example.com")) let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&e=mac@example.com&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&eb=\(binding)&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .connected) @@ -1582,6 +1585,7 @@ final class TerminalOutputCollector { @MainActor @Test func minimalPairingCodeBuildMismatchWarnsAndContinuesAfterAcceptance() async throws { + let binding = try #require(CmxPairingQRCode.emailBinding(for: "user@example.com")) let responses = ScriptedTransportResponses([ try rpcWorkspaceListFrame(workspaceID: "qr-workspace", title: "QR Workspace"), try rpcHostStatusFrame( @@ -1595,6 +1599,7 @@ final class TerminalOutputCollector { transportFactory: ScriptedTransportFactory(responses: responses), supportsServerPushEvents: false ) + let analytics = RecordingAnalytics() let store = CMUXMobileShellStore( runtime: runtime, workspaces: PreviewMobileHost.workspaces, @@ -1602,6 +1607,7 @@ final class TerminalOutputCollector { currentUserIDValue: "phone-user", currentUserEmailValue: "user@example.com" ), + analytics: analytics, feedbackStampProvider: { MobileFeedbackStamp( buildType: .dev, @@ -1616,13 +1622,14 @@ final class TerminalOutputCollector { store.signIn() let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&e=user@example.com&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&eb=\(binding)&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .needsUserApproval) #expect(store.connectionState == .disconnected) #expect(store.pairingVersionWarning?.contains("0.65.0 (10)") == true) #expect(store.pairingVersionWarning?.contains("0.65.0 (9)") == true) + #expect(analytics.eventCount(named: "ios_pairing_started") == 0) #expect(try await responses.sentRequests().isEmpty) await store.acceptPairingVersionWarning() @@ -1630,6 +1637,8 @@ final class TerminalOutputCollector { #expect(store.pairingVersionWarning == nil) #expect(store.connectionState == .connected) #expect(store.selectedWorkspace?.id.rawValue == "qr-workspace") + #expect(analytics.eventCount(named: "ios_pairing_started") == 1) + #expect(analytics.eventCount(named: "ios_pairing_succeeded") == 1) #expect(try await responses.sentRequests().contains { $0.method == "workspace.list" }) } @@ -2496,6 +2505,29 @@ private struct TestIdentityProvider: MobileIdentityProviding { @MainActor var currentUserEmail: String? { currentUserEmailValue } } +private final class RecordingAnalytics: AnalyticsEmitting, @unchecked Sendable { + private let lock = NSLock() + private var events: [String] = [] + + func capture(_ event: String, _ properties: [String: AnalyticsValue]) { + lock.lock() + events.append(event) + lock.unlock() + } + + func identify(userId: String?, alias: String?, properties: [String: AnalyticsValue]) {} + + func setSuperProperties(_ properties: [String: AnalyticsValue]) {} + + func flush() async {} + + func eventCount(named name: String) -> Int { + lock.lock() + defer { lock.unlock() } + return events.filter { $0 == name }.count + } +} + private func testRuntime( supportedRouteKinds: [CmxAttachTransportKind] = [.tailscale, .debugLoopback, .websocket], transportFactory: any CmxByteTransportFactory, From b363f86c0e51e57e43232a3d3b17ae6852101122 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:38:11 -0700 Subject: [PATCH 13/31] Use opaque account binding in pairing QR --- .../CMUXMobileCore/CmxPairingQRCode.swift | 32 ++++--------------- .../Sources/CMUXMobileCore/CmxTransport.swift | 16 +++++----- .../CmxPairingQRCodeTests.swift | 8 ++--- .../CmxAttachTicketInputTests.swift | 5 ++- .../MobileShellComposite.swift | 24 ++++++++------ Sources/Mobile/MobileAttachTicketStore.swift | 2 ++ Sources/Mobile/MobileHostService.swift | 1 + .../cmuxFeatureTests/cmuxFeatureTests.swift | 9 ++---- 8 files changed, 42 insertions(+), 55 deletions(-) diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift index 081891bb93d..f21c5c90832 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift @@ -1,15 +1,14 @@ -import CryptoKit import Foundation /// The minimal pairing-QR grammar: expected Mac account/build metadata plus /// plain `host:port` routes in the URL query. /// -/// `cmux-ios://attach?v=2&eb=&av=&ab=&r=:[&r=:...]` +/// `cmux-ios://attach?v=2&ub=&av=&ab=&r=:[&r=:...]` /// /// 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 a -/// one-way binding of the normalized email, never the email itself. Everything -/// else the earlier grammars carried has a better channel or no reason to exist: +/// 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. @@ -65,8 +64,8 @@ public struct CmxPairingQRCode: Sendable { return nil } var items: [String] = ["v=\(Self.version)"] - if let emailBinding = Self.emailBinding(for: ticket.macUserEmail) { - items.append("eb=\(percentEncodeQueryValue(emailBinding))") + if let userID = normalizedNonEmpty(ticket.macUserID) { + items.append("ub=\(percentEncodeQueryValue(userID))") } if let version = normalizedNonEmpty(ticket.macAppVersion) { items.append("av=\(percentEncodeQueryValue(version))") @@ -91,18 +90,6 @@ public struct CmxPairingQRCode: Sendable { encodableRoutes(of: ticket) != nil } - /// Returns the public QR-safe binding for `email`, or `nil` when there is - /// no non-empty email to bind. The binding is deterministic so a signed-in - /// phone can check its own email before dialing, but the QR never exposes - /// the Mac account email in plain text. - public static func emailBinding(for email: String?) -> String? { - guard let normalized = normalizedEmail(email) else { return nil } - let input = Data("cmux-ios-pairing-email-v1\0\(normalized)".utf8) - return "sha256:" + SHA256.hash(data: input).map { byte in - String(format: "%02x", byte) - }.joined() - } - /// The route subsequence a v2 pairing URL would carry for `ticket`, or /// `nil` when the ticket is not expressible in the minimal grammar. /// @@ -197,7 +184,7 @@ public struct CmxPairingQRCode: Sendable { macDeviceID: "", macDisplayName: nil, macUserEmail: queryValue(named: "e", in: components), - macUserEmailBinding: queryValue(named: "eb", in: components), + macUserID: queryValue(named: "ub", in: components), macAppVersion: queryValue(named: "av", in: components), macAppBuild: queryValue(named: "ab", in: components), routes: routes, @@ -282,11 +269,6 @@ private extension CmxPairingQRCode { return trimmed?.isEmpty == false ? trimmed : nil } - static func normalizedEmail(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return trimmed?.isEmpty == false ? trimmed : nil - } - func percentEncodeQueryValue(_ value: String) -> String { var allowed = CharacterSet.urlQueryAllowed allowed.remove(charactersIn: "&=+") diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift index a1eab549df9..e454761b537 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift @@ -176,7 +176,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { case macDeviceID case macDisplayName case macUserEmail - case macUserEmailBinding + case macUserID case macAppVersion case macAppBuild case routes @@ -201,10 +201,10 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { public let macDisplayName: String? /// The signed-in Mac account email the phone must match before pairing. public let macUserEmail: String? - /// A one-way normalized-email binding used by public pairing QR codes so - /// the phone can reject the wrong signed-in account without exposing the - /// Mac account email in the QR payload. - public let macUserEmailBinding: 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? /// The Mac app's marketing version, used for warning-only compatibility checks. public let macAppVersion: String? /// The Mac app's build number, displayed with version mismatch warnings when present. @@ -227,7 +227,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { macDeviceID: container.decode(String.self, forKey: .macDeviceID), macDisplayName: container.decodeIfPresent(String.self, forKey: .macDisplayName), macUserEmail: container.decodeIfPresent(String.self, forKey: .macUserEmail), - macUserEmailBinding: container.decodeIfPresent(String.self, forKey: .macUserEmailBinding), + macUserID: container.decodeIfPresent(String.self, forKey: .macUserID), macAppVersion: container.decodeIfPresent(String.self, forKey: .macAppVersion), macAppBuild: container.decodeIfPresent(String.self, forKey: .macAppBuild), routes: container.decode([CmxAttachRoute].self, forKey: .routes), @@ -258,7 +258,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { macDeviceID: String, macDisplayName: String?, macUserEmail: String? = nil, - macUserEmailBinding: String? = nil, + macUserID: String? = nil, macAppVersion: String? = nil, macAppBuild: String? = nil, routes: [CmxAttachRoute], @@ -271,7 +271,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { self.macDeviceID = macDeviceID self.macDisplayName = macDisplayName self.macUserEmail = macUserEmail - self.macUserEmailBinding = macUserEmailBinding + self.macUserID = macUserID self.macAppVersion = macAppVersion self.macAppBuild = macAppBuild self.routes = routes diff --git a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift index 17dd2514694..c807074c7f2 100644 --- a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift +++ b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift @@ -72,13 +72,14 @@ import Testing #expect(decoded.routes.map(\.priority) == [10, 20]) } - @Test func roundTripsEmailBindingAndBuildMetadataWithoutExposingEmail() throws { + @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", macAppVersion: "0.64.15", macAppBuild: "42", routes: [ @@ -89,8 +90,7 @@ import Testing ) let url = try #require(CmxPairingQRCode().encode(ticket)) - let binding = try #require(CmxPairingQRCode.emailBinding(for: "Lawrence@Example.com")) - #expect(url.contains("eb=\(binding)")) + #expect(url.contains("ub=user_mac_123")) #expect(!url.contains("Lawrence@Example.com")) #expect(!url.lowercased().contains("lawrence@example.com")) #expect(url.contains("av=0.64.15")) @@ -98,7 +98,7 @@ import Testing let decoded = try CmxPairingQRCode().decode(try components(url)) #expect(decoded.macUserEmail == nil) - #expect(decoded.macUserEmailBinding == binding) + #expect(decoded.macUserID == "user_mac_123") #expect(decoded.macAppVersion == "0.64.15") #expect(decoded.macAppBuild == "42") #expect(decoded.routes == ticket.routes) diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift index 771e46e6812..5fd9a3eb6c0 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift @@ -127,9 +127,8 @@ import Testing // New-phone-scans-new-QR: the minimal v2 grammar (bare routes, no // payload blob) routes through the same input decoder as everything // else the scanner or a deep link can hand us. - let binding = try #require(CmxPairingQRCode.emailBinding(for: "user@example.com")) let decoded = try CmxAttachTicketInput.decode( - "cmux-ios://attach?v=2&eb=\(binding)&av=0.64.15&ab=42&r=lawrences-mac.tail1234.ts.net:58465&r=100.64.0.5:58465" + "cmux-ios://attach?v=2&ub=user_mac_123&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 == "") @@ -137,7 +136,7 @@ import Testing #expect(decoded.expiresAt == nil) #expect(decoded.authToken == nil) #expect(decoded.macUserEmail == nil) - #expect(decoded.macUserEmailBinding == binding) + #expect(decoded.macUserID == "user_mac_123") #expect(decoded.macAppVersion == "0.64.15") #expect(decoded.macAppBuild == "42") #expect(decoded.routes.count == 2) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 2e2ebb29bd0..eaee581bfa0 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1915,7 +1915,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { macDeviceID: reportedID, macDisplayName: ticket.macDisplayName, macUserEmail: ticket.macUserEmail, - macUserEmailBinding: ticket.macUserEmailBinding, + macUserID: ticket.macUserID, macAppVersion: ticket.macAppVersion, macAppBuild: ticket.macAppBuild, routes: ticket.routes, @@ -2063,7 +2063,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return .failed } - if let emailFailure = Self.emailFailure(for: ticket, actualEmail: identityProvider?.currentUserEmail) { + if let emailFailure = Self.emailFailure( + for: ticket, + actualUserID: identityProvider?.currentUserID, + actualEmail: identityProvider?.currentUserEmail + ) { applyPairingFailure(emailFailure, phase: "validation") if connectionState != .connected { connectionState = .disconnected @@ -3170,8 +3174,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { static func emailFailure( for ticket: CmxAttachTicket, + actualUserID: String?, actualEmail: String? ) -> MobilePairingFailureCategory? { + if let expectedUserID = mobileShellNormalizedNonEmpty(ticket.macUserID) { + guard let actualUserID = mobileShellNormalizedNonEmpty(actualUserID) else { return nil } + guard actualUserID == expectedUserID else { + return .authFailed + } + return nil + } guard let actual = mobileShellNormalizedEmail(actualEmail) else { return nil } if let expected = mobileShellNormalizedEmail(ticket.macUserEmail) { guard actual == expected else { @@ -3179,12 +3191,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } return nil } - guard let expectedBinding = mobileShellNormalizedNonEmpty(ticket.macUserEmailBinding) else { - return nil - } - guard CmxPairingQRCode.emailBinding(for: actual) == expectedBinding else { - return .authFailed - } return nil } @@ -4876,7 +4882,7 @@ private extension CmxAttachTicket { macDeviceID: macDeviceID, macDisplayName: macDisplayName ?? fallbackDisplayName, macUserEmail: macUserEmail, - macUserEmailBinding: macUserEmailBinding, + macUserID: macUserID, macAppVersion: macAppVersion, macAppBuild: macAppBuild, routes: routes, diff --git a/Sources/Mobile/MobileAttachTicketStore.swift b/Sources/Mobile/MobileAttachTicketStore.swift index da8c8ee3562..9c49dab3d41 100644 --- a/Sources/Mobile/MobileAttachTicketStore.swift +++ b/Sources/Mobile/MobileAttachTicketStore.swift @@ -28,6 +28,7 @@ final class MobileAttachTicketStore { routes: [CmxAttachRoute], ttl: TimeInterval, macUserEmail: String? = nil, + macUserID: String? = nil, macAppVersion: String? = nil, macAppBuild: String? = nil, now: Date = Date() @@ -46,6 +47,7 @@ final class MobileAttachTicketStore { macDeviceID: MobileHostIdentity.deviceID(), macDisplayName: MobileHostIdentity.displayName(), macUserEmail: macUserEmail, + macUserID: macUserID, macAppVersion: macAppVersion, macAppBuild: macAppBuild, routes: routes, diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index fe43eedef6a..11228c8f2db 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1069,6 +1069,7 @@ final class MobileHostService { routes: selectedRoutes, ttl: ttl, macUserEmail: await currentAuthenticatedLocalUserEmail(), + macUserID: await currentAuthenticatedLocalUserID(), macAppVersion: MobileHostBuildIdentity.current().appVersion, macAppBuild: MobileHostBuildIdentity.current().appBuild ) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index fba819313c0..083b412ce06 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -1540,9 +1540,8 @@ final class TerminalOutputCollector { ) store.signIn() - let binding = try #require(CmxPairingQRCode.emailBinding(for: "mac@example.com")) let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&eb=\(binding)&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&ub=mac-user&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .failed) @@ -1572,9 +1571,8 @@ final class TerminalOutputCollector { ) store.signIn() - let binding = try #require(CmxPairingQRCode.emailBinding(for: "mac@example.com")) let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&eb=\(binding)&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&ub=mac-user&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .connected) @@ -1585,7 +1583,6 @@ final class TerminalOutputCollector { @MainActor @Test func minimalPairingCodeBuildMismatchWarnsAndContinuesAfterAcceptance() async throws { - let binding = try #require(CmxPairingQRCode.emailBinding(for: "user@example.com")) let responses = ScriptedTransportResponses([ try rpcWorkspaceListFrame(workspaceID: "qr-workspace", title: "QR Workspace"), try rpcHostStatusFrame( @@ -1622,7 +1619,7 @@ final class TerminalOutputCollector { store.signIn() let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&eb=\(binding)&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&ub=phone-user&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .needsUserApproval) From 74632bb11c0343e60fb0fcec9dfe675390f95679 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:47:12 -0700 Subject: [PATCH 14/31] Use pairing compatibility for mobile warning --- .../CmxAttachTicketCompactCoder.swift | 11 ++-- .../CMUXMobileCore/CmxPairingQRCode.swift | 11 +++- .../Sources/CMUXMobileCore/CmxTransport.swift | 11 +++- .../CMUXMobileCore/CompactAttachTicket.swift | 3 ++ .../CMUXMobileCore/MobileSyncProtocol.swift | 3 ++ .../CmxAttachTicketCompactCoderTests.swift | 4 ++ .../CmxPairingQRCodeTests.swift | 3 ++ .../CmxAttachTicketInputTests.swift | 5 +- .../MobileShellComposite.swift | 42 ++++++++++----- Sources/Mobile/MobileAttachTicketStore.swift | 2 + Sources/Mobile/MobileHostService.swift | 1 + cmuxTests/MobileHostAuthorizationTests.swift | 43 ++++++++------- ios/cmux/Resources/Localizable.xcstrings | 25 +++++++-- .../cmuxFeatureTests/cmuxFeatureTests.swift | 53 +++++++++++++++++-- 14 files changed, 169 insertions(+), 48 deletions(-) diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift index 61d8504b3e5..017c6484c83 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift @@ -9,9 +9,10 @@ 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. It does keep non-secret pairing context: the Mac account email and -/// app version/build, so the phone can fail fast on an account mismatch and -/// warn before continuing across version skew. A pairing QR never expires; +/// 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. @@ -28,8 +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, `u` Mac account email, `av` app version, -/// `ab` app build, `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. diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift index f21c5c90832..3b0db8b7737 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift @@ -3,7 +3,7 @@ import Foundation /// The minimal pairing-QR grammar: expected Mac account/build metadata plus /// plain `host:port` routes in the URL query. /// -/// `cmux-ios://attach?v=2&ub=&av=&ab=&r=:[&r=:...]` +/// `cmux-ios://attach?v=2&ub=&pc=&av=&ab=&r=:[&r=:...]` /// /// 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 @@ -67,6 +67,9 @@ public struct CmxPairingQRCode: Sendable { 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))") } @@ -185,6 +188,7 @@ public struct CmxPairingQRCode: Sendable { 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, @@ -264,6 +268,11 @@ private extension CmxPairingQRCode { 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 diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift index e454761b537..3a254ae2265 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift @@ -177,6 +177,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { case macDisplayName case macUserEmail case macUserID + case macPairingCompatibilityVersion case macAppVersion case macAppBuild case routes @@ -205,7 +206,9 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { /// 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? - /// The Mac app's marketing version, used for warning-only compatibility checks. + /// 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? @@ -228,6 +231,10 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { 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), @@ -259,6 +266,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { macDisplayName: String?, macUserEmail: String? = nil, macUserID: String? = nil, + macPairingCompatibilityVersion: Int? = nil, macAppVersion: String? = nil, macAppBuild: String? = nil, routes: [CmxAttachRoute], @@ -272,6 +280,7 @@ public struct CmxAttachTicket: Codable, Equatable, Sendable { self.macDisplayName = macDisplayName self.macUserEmail = macUserEmail self.macUserID = macUserID + self.macPairingCompatibilityVersion = macPairingCompatibilityVersion self.macAppVersion = macAppVersion self.macAppBuild = macAppBuild self.routes = routes diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift index 94c69f2e440..caa6475efab 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift @@ -13,6 +13,7 @@ struct CompactAttachTicket: Codable { let t: String? let d: String let u: String? + let pc: Int? let av: String? let ab: String? let r: [CompactAttachRoute] @@ -23,6 +24,7 @@ struct CompactAttachTicket: Codable { 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) @@ -36,6 +38,7 @@ struct CompactAttachTicket: Codable { macDeviceID: d, macDisplayName: nil, macUserEmail: u, + macPairingCompatibilityVersion: pc, macAppVersion: av, macAppBuild: ab, routes: Self.expandedRoutes(r), diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileSyncProtocol.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileSyncProtocol.swift index bd9fed2abdc..d58d440faf5 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileSyncProtocol.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileSyncProtocol.swift @@ -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 { diff --git a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift index e2adb9576cf..df5f259f84c 100644 --- a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift +++ b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift @@ -42,6 +42,7 @@ private func legacyDecoder() -> JSONDecoder { macDeviceID: "mac-1", macDisplayName: "Studio", macUserEmail: "user@example.com", + macPairingCompatibilityVersion: 1, macAppVersion: "0.64.15", macAppBuild: "42", routes: [try hostPortRoute(priority: 1)], @@ -61,6 +62,7 @@ private func legacyDecoder() -> JSONDecoder { #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 @@ -101,6 +103,7 @@ private func legacyDecoder() -> JSONDecoder { macDeviceID: "mac-1", macDisplayName: "Studio", macUserEmail: "user@example.com", + macPairingCompatibilityVersion: 1, macAppVersion: "0.64.15", macAppBuild: "42", routes: routes, @@ -117,6 +120,7 @@ private func legacyDecoder() -> JSONDecoder { #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 diff --git a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift index c807074c7f2..3a640ee58b7 100644 --- a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift +++ b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift @@ -80,6 +80,7 @@ import Testing macDisplayName: "Lawrence's Mac", macUserEmail: "Lawrence@Example.com", macUserID: "user_mac_123", + macPairingCompatibilityVersion: 1, macAppVersion: "0.64.15", macAppBuild: "42", routes: [ @@ -93,12 +94,14 @@ import Testing #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) diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift index 5fd9a3eb6c0..c03435a2658 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift @@ -15,6 +15,7 @@ import Testing macDeviceID: "mac-1", macDisplayName: "Studio", macUserEmail: "user@example.com", + macPairingCompatibilityVersion: 1, macAppVersion: "0.64.15", macAppBuild: "42", routes: [ @@ -45,6 +46,7 @@ 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 == "") @@ -128,7 +130,7 @@ 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&ub=user_mac_123&av=0.64.15&ab=42&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 == "") @@ -137,6 +139,7 @@ import Testing #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) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index eaee581bfa0..e2d3cc3b77d 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1916,6 +1916,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { macDisplayName: ticket.macDisplayName, macUserEmail: ticket.macUserEmail, macUserID: ticket.macUserID, + macPairingCompatibilityVersion: ticket.macPairingCompatibilityVersion, macAppVersion: ticket.macAppVersion, macAppBuild: ticket.macAppBuild, routes: ticket.routes, @@ -3195,24 +3196,29 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } private func versionWarning(for ticket: CmxAttachTicket) -> String? { - guard let macVersion = mobileShellNormalizedNonEmpty(ticket.macAppVersion) else { return nil } - let phoneStamp = feedbackStampProvider() - guard let phoneVersion = mobileShellNormalizedNonEmpty(phoneStamp.appVersion) else { + guard let macCompatibilityVersion = ticket.macPairingCompatibilityVersion, + macCompatibilityVersion != CmxMobileDefaults.pairingCompatibilityVersion else { return nil } - let phoneBuild = mobileShellNormalizedNonEmpty(phoneStamp.appBuild) - let macBuild = mobileShellNormalizedNonEmpty(ticket.macAppBuild) - let versionDiffers = phoneVersion != macVersion - let buildDiffers = phoneBuild != nil && macBuild != nil && phoneBuild != macBuild - guard versionDiffers || buildDiffers else { return nil } + let phoneStamp = feedbackStampProvider() + let phoneVersion = mobileShellNormalizedNonEmpty(phoneStamp.appVersion) + let macVersion = mobileShellNormalizedNonEmpty(ticket.macAppVersion) let format = L10n.string( "mobile.pairing.versionWarningFormat", - defaultValue: "This iPhone is running cmux %@, but the Mac is running cmux %@. Pairing across different versions can break terminal input, workspace sync, or notifications. Continue only if you trust this Mac and accept that some features may fail." + defaultValue: "This iPhone is running cmux %@, but the Mac is running cmux %@. Pairing across different compatibility levels can break terminal input, workspace sync, or notifications. Continue only if you trust this Mac and accept that some features may fail." ) return String( format: format, - mobileShellVersionDisplay(version: phoneVersion, build: phoneStamp.appBuild), - mobileShellVersionDisplay(version: macVersion, build: ticket.macAppBuild) + mobileShellVersionDisplay( + version: phoneVersion, + build: phoneStamp.appBuild, + compatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion + ), + mobileShellVersionDisplay( + version: macVersion, + build: ticket.macAppBuild, + compatibilityVersion: macCompatibilityVersion + ) ) } @@ -4857,7 +4863,18 @@ private struct MobileManualAttachTicketCreateResponse: Decodable, Sendable { } } -private func mobileShellVersionDisplay(version: String, build: String?) -> String { +private func mobileShellVersionDisplay( + version: String?, + build: String?, + compatibilityVersion: Int +) -> String { + let version = version ?? String( + format: L10n.string( + "mobile.pairing.compatibilityDisplayFormat", + defaultValue: "compatibility %@" + ), + "\(compatibilityVersion)" + ) guard let build = mobileShellNormalizedNonEmpty(build) else { return version } return "\(version) (\(build))" } @@ -4883,6 +4900,7 @@ private extension CmxAttachTicket { macDisplayName: macDisplayName ?? fallbackDisplayName, macUserEmail: macUserEmail, macUserID: macUserID, + macPairingCompatibilityVersion: macPairingCompatibilityVersion, macAppVersion: macAppVersion, macAppBuild: macAppBuild, routes: routes, diff --git a/Sources/Mobile/MobileAttachTicketStore.swift b/Sources/Mobile/MobileAttachTicketStore.swift index 9c49dab3d41..e488333050c 100644 --- a/Sources/Mobile/MobileAttachTicketStore.swift +++ b/Sources/Mobile/MobileAttachTicketStore.swift @@ -29,6 +29,7 @@ final class MobileAttachTicketStore { ttl: TimeInterval, macUserEmail: String? = nil, macUserID: String? = nil, + macPairingCompatibilityVersion: Int? = nil, macAppVersion: String? = nil, macAppBuild: String? = nil, now: Date = Date() @@ -48,6 +49,7 @@ final class MobileAttachTicketStore { macDisplayName: MobileHostIdentity.displayName(), macUserEmail: macUserEmail, macUserID: macUserID, + macPairingCompatibilityVersion: macPairingCompatibilityVersion, macAppVersion: macAppVersion, macAppBuild: macAppBuild, routes: routes, diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 11228c8f2db..6f1b19e73c3 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1070,6 +1070,7 @@ final class MobileHostService { ttl: ttl, macUserEmail: await currentAuthenticatedLocalUserEmail(), macUserID: await currentAuthenticatedLocalUserID(), + macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion, macAppVersion: MobileHostBuildIdentity.current().appVersion, macAppBuild: MobileHostBuildIdentity.current().appBuild ) diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index 6f9a8381e64..c74cabbe036 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -311,7 +311,7 @@ struct MobileHostAuthorizationTests { ] } ) - await started.wait() + try await started.wait() resolver.refreshTailscaleRoutes( resolveHosts: { @@ -324,7 +324,7 @@ struct MobileHostAuthorizationTests { ) gate.signal() - await callback.wait() + try await callback.wait() #expect(observedHosts.value() == [ "work-mac.tailnet.ts.net", "100.71.210.41", @@ -1205,32 +1205,35 @@ private actor MobileHostConnectionBox { } } +private enum AsyncTestSignalError: Error { + case timedOut +} + private final class AsyncTestSignal: @unchecked Sendable { - private let lock = NSLock() + private let condition = NSCondition() private var fulfilled = false - private var continuations: [CheckedContinuation] = [] func fulfill() { - lock.lock() + condition.lock() fulfilled = true - let continuations = continuations - self.continuations = [] - lock.unlock() - for continuation in continuations { - continuation.resume() - } + condition.broadcast() + condition.unlock() + } + + func wait(timeout: TimeInterval = 1) async throws { + try await Task.detached { [self] in + try blockingWait(timeout: timeout) + }.value } - func wait() async { - await withCheckedContinuation { continuation in - lock.lock() - if fulfilled { - lock.unlock() - continuation.resume() - return + private func blockingWait(timeout: TimeInterval) throws { + let deadline = Date().addingTimeInterval(timeout) + condition.lock() + defer { condition.unlock() } + while !fulfilled { + if !condition.wait(until: deadline) { + throw AsyncTestSignalError.timedOut } - continuations.append(continuation) - lock.unlock() } } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ec131d33565..e44e527958a 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -4342,13 +4342,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "This iPhone is running cmux %@, but the Mac is running cmux %@. Pairing across different versions can break terminal input, workspace sync, or notifications. Continue only if you trust this Mac and accept that some features may fail." + "value": "This iPhone is running cmux %@, but the Mac is running cmux %@. Pairing across different compatibility levels can break terminal input, workspace sync, or notifications. Continue only if you trust this Mac and accept that some features may fail." } }, "ja": { "stringUnit": { "state": "translated", - "value": "この iPhone は cmux %@ を実行していますが、Mac は cmux %@ を実行しています。異なるバージョン間でペアリングすると、ターミナル入力、ワークスペース同期、通知が壊れる可能性があります。この Mac を信頼し、一部の機能が失敗する可能性を受け入れる場合のみ続行してください。" + "value": "この iPhone は cmux %@ を実行していますが、Mac は cmux %@ を実行しています。異なる互換性レベル間でペアリングすると、ターミナル入力、ワークスペース同期、通知が壊れる可能性があります。この Mac を信頼し、一部の機能が失敗する可能性を受け入れる場合のみ続行してください。" } } } @@ -4359,13 +4359,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Version mismatch" + "value": "Compatibility mismatch" } }, "ja": { "stringUnit": { "state": "translated", - "value": "バージョンが一致していません" + "value": "互換性が一致していません" } } } @@ -4404,6 +4404,23 @@ } } }, + "mobile.pairing.compatibilityDisplayFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "compatibility %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "互換性 %@" + } + } + } + }, "mobile.pairing.dnsFailedFormat": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 083b412ce06..1e8b889d658 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -396,7 +396,7 @@ final class TerminalOutputCollector { #expect(store.activeTicket?.macDeviceID == "active-mac") let warningResult = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&pc=2&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(warningResult == .needsUserApproval) @@ -455,7 +455,7 @@ final class TerminalOutputCollector { await router.waitForFirstWorkspaceListRequest() let warningResult = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&pc=2&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) await router.releaseFirstWorkspaceListResponse() let slowResult = await slowTask.value @@ -1582,7 +1582,7 @@ final class TerminalOutputCollector { } @MainActor -@Test func minimalPairingCodeBuildMismatchWarnsAndContinuesAfterAcceptance() async throws { +@Test func minimalPairingCodeCompatibilityMismatchWarnsAndContinuesAfterAcceptance() async throws { let responses = ScriptedTransportResponses([ try rpcWorkspaceListFrame(workspaceID: "qr-workspace", title: "QR Workspace"), try rpcHostStatusFrame( @@ -1619,7 +1619,7 @@ final class TerminalOutputCollector { store.signIn() let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&ub=phone-user&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&ub=phone-user&pc=2&av=0.65.0&ab=9&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .needsUserApproval) @@ -1639,6 +1639,51 @@ final class TerminalOutputCollector { #expect(try await responses.sentRequests().contains { $0.method == "workspace.list" }) } +@MainActor +@Test func minimalPairingCodeAppVersionMismatchDoesNotWarnWhenCompatibilityMatches() async throws { + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "qr-workspace", title: "QR Workspace"), + try rpcHostStatusFrame( + renderGrid: false, + macDeviceID: "status-reported-mac", + macDisplayName: "Status Mac" + ), + ]) + let runtime = testRuntime( + supportedRouteKinds: [.tailscale], + transportFactory: ScriptedTransportFactory(responses: responses), + supportsServerPushEvents: false + ) + let store = CMUXMobileShellStore( + runtime: runtime, + workspaces: PreviewMobileHost.workspaces, + identityProvider: TestIdentityProvider( + currentUserIDValue: "phone-user", + currentUserEmailValue: "user@example.com" + ), + feedbackStampProvider: { + MobileFeedbackStamp( + buildType: .dev, + appVersion: "1.0.0", + appBuild: "10", + bundleIdentifier: "dev.cmux.ios.test", + osVersion: "iOS test", + deviceModel: "test" + ) + } + ) + + store.signIn() + let result = await store.connectPairingURLResult( + "cmux-ios://attach?v=2&ub=phone-user&pc=1&av=0.65.0&ab=95&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + ) + + #expect(result == .connected) + #expect(store.pairingVersionWarning == nil) + #expect(store.connectionState == .connected) + #expect(store.selectedWorkspace?.id.rawValue == "qr-workspace") +} + @MainActor @Test func minimalPairingCodePersistsPairedMacWithoutServerPushEvents() async throws { // Identity recovery for an anonymous v2 ticket must not be coupled to From c31354032e04e608c35b9ed16969d09a1163f49d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:09:43 -0700 Subject: [PATCH 15/31] Align compatibility warning title fallback --- .../Sources/CmuxMobileShellUI/PairingView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift index 5d5d38ee4c4..3a0fcd454b8 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift @@ -148,7 +148,7 @@ struct PairingView: View { Section { VStack(alignment: .leading, spacing: 10) { Label { - Text(L10n.string("mobile.pairing.versionWarningTitle", defaultValue: "Version mismatch")) + Text(L10n.string("mobile.pairing.versionWarningTitle", defaultValue: "Compatibility mismatch")) } icon: { Image(systemName: "exclamationmark.triangle.fill") } From becf69f3183ed9868cd54e5827977a00b8e76a5e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:14:09 -0700 Subject: [PATCH 16/31] Restore mobile notification sync topics --- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index e2d3cc3b77d..a51b9f3223f 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -40,9 +40,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { var eventTopics: [String] { switch self { case .renderGrid: - return ["workspace.updated", "terminal.render_grid"] + return ["workspace.updated", "terminal.render_grid", "notification.dismissed", "notification.badge"] case .rawBytes: - return ["workspace.updated", "terminal.bytes"] + return ["workspace.updated", "terminal.bytes", "notification.dismissed", "notification.badge"] } } } From b3e301989e88624b19b40d45e3954e5c5e9c9b57 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:20:05 -0700 Subject: [PATCH 17/31] Warn for legacy pairing QR compatibility --- .../CMUXMobileCore/CmxPairingQRCode.swift | 2 +- .../MobileShellComposite.swift | 18 +++++-- ios/cmux/Resources/Localizable.xcstrings | 17 +++++++ .../cmuxFeatureTests/cmuxFeatureTests.swift | 48 +++++++++++++++++-- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift index 3b0db8b7737..775d86c6969 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift @@ -188,7 +188,7 @@ public struct CmxPairingQRCode: Sendable { macDisplayName: nil, macUserEmail: queryValue(named: "e", in: components), macUserID: queryValue(named: "ub", in: components), - macPairingCompatibilityVersion: queryInt(named: "pc", in: components), + macPairingCompatibilityVersion: queryInt(named: "pc", in: components) ?? 0, macAppVersion: queryValue(named: "av", in: components), macAppBuild: queryValue(named: "ab", in: components), routes: routes, diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index a51b9f3223f..f8d8336ded1 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -4866,17 +4866,27 @@ private struct MobileManualAttachTicketCreateResponse: Decodable, Sendable { private func mobileShellVersionDisplay( version: String?, build: String?, - compatibilityVersion: Int + compatibilityVersion: Int? ) -> String { - let version = version ?? String( + let version = version ?? mobileShellCompatibilityDisplay(compatibilityVersion) + guard let build = mobileShellNormalizedNonEmpty(build) else { return version } + return "\(version) (\(build))" +} + +private func mobileShellCompatibilityDisplay(_ compatibilityVersion: Int?) -> String { + guard let compatibilityVersion, compatibilityVersion > 0 else { + return L10n.string( + "mobile.pairing.compatibilityUnknown", + defaultValue: "unknown compatibility" + ) + } + return String( format: L10n.string( "mobile.pairing.compatibilityDisplayFormat", defaultValue: "compatibility %@" ), "\(compatibilityVersion)" ) - guard let build = mobileShellNormalizedNonEmpty(build) else { return version } - return "\(version) (\(build))" } private func mobileShellNormalizedEmail(_ value: String?) -> String? { diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index e44e527958a..03b3128856e 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -4421,6 +4421,23 @@ } } }, + "mobile.pairing.compatibilityUnknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "unknown compatibility" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明な互換性" + } + } + } + }, "mobile.pairing.dnsFailedFormat": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 1e8b889d658..de469b407e0 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -1491,7 +1491,7 @@ final class TerminalOutputCollector { ) store.signIn() - await store.connectPairingURL("cmux-ios://attach?v=2&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)") + await store.connectPairingURL("cmux-ios://attach?v=2&pc=1&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)") #expect(store.connectionState == .connected) // Until the status reply lands, the dialed Tailscale host stands in for @@ -1541,7 +1541,7 @@ final class TerminalOutputCollector { store.signIn() let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&ub=mac-user&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&ub=mac-user&pc=1&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .failed) @@ -1572,7 +1572,7 @@ final class TerminalOutputCollector { store.signIn() let result = await store.connectPairingURLResult( - "cmux-ios://attach?v=2&ub=mac-user&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + "cmux-ios://attach?v=2&ub=mac-user&pc=1&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" ) #expect(result == .connected) @@ -1639,6 +1639,46 @@ final class TerminalOutputCollector { #expect(try await responses.sentRequests().contains { $0.method == "workspace.list" }) } +@MainActor +@Test func minimalPairingCodeMissingCompatibilityWarnsBeforeDialing() async throws { + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "qr-workspace", title: "QR Workspace"), + ]) + let runtime = testRuntime( + supportedRouteKinds: [.tailscale], + transportFactory: ScriptedTransportFactory(responses: responses), + supportsServerPushEvents: false + ) + let store = CMUXMobileShellStore( + runtime: runtime, + workspaces: PreviewMobileHost.workspaces, + identityProvider: TestIdentityProvider( + currentUserIDValue: "phone-user", + currentUserEmailValue: "user@example.com" + ), + feedbackStampProvider: { + MobileFeedbackStamp( + buildType: .dev, + appVersion: "1.0.0", + appBuild: "10", + bundleIdentifier: "dev.cmux.ios.test", + osVersion: "iOS test", + deviceModel: "test" + ) + } + ) + + store.signIn() + let result = await store.connectPairingURLResult( + "cmux-ios://attach?v=2&ub=phone-user&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)" + ) + + #expect(result == .needsUserApproval) + #expect(store.connectionState == .disconnected) + #expect(store.pairingVersionWarning?.contains("unknown compatibility") == true) + #expect(try await responses.sentRequests().isEmpty) +} + @MainActor @Test func minimalPairingCodeAppVersionMismatchDoesNotWarnWhenCompatibilityMatches() async throws { let responses = ScriptedTransportResponses([ @@ -1716,7 +1756,7 @@ final class TerminalOutputCollector { ) store.signIn() - await store.connectPairingURL("cmux-ios://attach?v=2&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)") + await store.connectPairingURL("cmux-ios://attach?v=2&pc=1&r=100.71.210.41:\(CmxMobileDefaults.defaultHostPort)") #expect(store.connectionState == .connected) // The recovery request runs on its own task; poll briefly instead of From 26b87b56457000ade68a54d648773f4b93e934d5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:44:34 -0700 Subject: [PATCH 18/31] Restore mobile notification dismiss sync --- .../MobileNotificationBadgeEvent.swift | 32 +++ .../MobileNotificationDismissedEvent.swift | 46 ++++ .../MobileNotificationReconcileResponse.swift | 42 ++++ .../DeliveredNotificationClearing.swift | 51 +++++ ...ellComposite+NotificationDismissSync.swift | 90 ++++++++ .../MobileShellComposite.swift | 45 +++- .../PendingNotificationDismissQueue.swift | 69 ++++++ .../SystemDeliveredNotificationClearer.swift | 70 ++++++ .../MobileShellDismissSyncTests.swift | 210 ++++++++++++++++++ ...PendingNotificationDismissQueueTests.swift | 70 ++++++ 10 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationBadgeEvent.swift create mode 100644 Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationDismissedEvent.swift create mode 100644 Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationReconcileResponse.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/PendingNotificationDismissQueue.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/SystemDeliveredNotificationClearer.swift create mode 100644 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift create mode 100644 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/PendingNotificationDismissQueueTests.swift diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationBadgeEvent.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationBadgeEvent.swift new file mode 100644 index 00000000000..0b79879d0e4 --- /dev/null +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationBadgeEvent.swift @@ -0,0 +1,32 @@ +public import Foundation + +/// Typed decoder for a `notification.badge` push-event payload. +/// +/// Emitted by the Mac whenever its unread-notification count changes (from the +/// same chokepoint that refreshes the Mac Dock badge), so an attached phone can +/// SET its app-icon badge to the authoritative total. The payload is just +/// `{"unread_count": Int}` — a count, never any terminal content. The phone +/// never does local badge arithmetic; every event sets the absolute total so +/// any drift self-heals on the next one. +public struct MobileNotificationBadgeEvent: Decodable, Sendable { + /// The Mac's authoritative unread-notification count. + public let unreadCount: Int? + + private enum CodingKeys: String, CodingKey { + case unreadCount = "unread_count" + } + + /// Decodes the payload, tolerating absent fields. + /// - Parameter decoder: The JSON decoder for the event payload. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + unreadCount = try container.decodeIfPresent(Int.self, forKey: .unreadCount) + } + + /// Decode a `notification.badge` event from a raw JSON payload. + /// - Parameter data: The event payload JSON. + /// - Returns: The decoded event, or `nil` when the payload is malformed. + public static func decode(_ data: Data) -> MobileNotificationBadgeEvent? { + try? JSONDecoder().decode(Self.self, from: data) + } +} diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationDismissedEvent.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationDismissedEvent.swift new file mode 100644 index 00000000000..9c5a9915cf6 --- /dev/null +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationDismissedEvent.swift @@ -0,0 +1,46 @@ +public import Foundation + +/// Typed decoder for a `notification.dismissed` push-event payload. +/// +/// Emitted by the Mac when one or more delivered notifications are dismissed or +/// cleared on the Mac (a banner swipe, "Mark read", or "Clear All"), so an +/// attached phone can clear the matching mirrored banners. The payload carries +/// only the stable notification ids (`{"ids": ["", …]}`) and never any +/// terminal content, so dismiss-sync is safe even when phone-forward +/// hideContent is enabled. +public struct MobileNotificationDismissedEvent: Decodable, Sendable { + /// The stable notification ids the Mac dismissed. The phone maps them to + /// delivered banners via the `cmux.notificationId` payload key each + /// forwarded push carries. + public let ids: [String] + + /// The Mac's authoritative unread-notification count after the dismiss, when + /// the host sends it (newer Macs). The phone SETS its app-icon badge to this + /// absolute total — never local arithmetic — so drift self-heals. `nil` from + /// an older Mac leaves the badge untouched. + public let unreadCount: Int? + + private enum CodingKeys: String, CodingKey { + case ids + case unreadCount = "unread_count" + } + + /// Decodes the payload, trimming and dropping blank ids and tolerating an + /// absent count. + /// - Parameter decoder: The JSON decoder for the event payload. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawIDs = try container.decodeIfPresent([String].self, forKey: .ids) ?? [] + ids = rawIDs + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + unreadCount = try container.decodeIfPresent(Int.self, forKey: .unreadCount) + } + + /// Decode a `notification.dismissed` event from a raw JSON payload. + /// - Parameter data: The event payload JSON. + /// - Returns: The decoded event, or `nil` when the payload is malformed. + public static func decode(_ data: Data) -> MobileNotificationDismissedEvent? { + try? JSONDecoder().decode(Self.self, from: data) + } +} diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationReconcileResponse.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationReconcileResponse.swift new file mode 100644 index 00000000000..07fc423801d --- /dev/null +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationReconcileResponse.swift @@ -0,0 +1,42 @@ +public import Foundation + +/// Typed decoder for the `notification.reconcile` RPC result. +/// +/// The phone's foreground/connect reconcile sweep sends the Mac the identifiers +/// of its currently delivered banners; the Mac answers with the subset that was +/// handled there (read in the store, or recently dismissed/removed) plus its +/// authoritative unread count. The phone removes the handled banners and SETS +/// its app-icon badge to the count, healing anything the live event or silent +/// push lanes missed while the app was closed. Carries only opaque ids and a +/// count, never terminal content. +public struct MobileNotificationReconcileResponse: Decodable, Sendable { + /// The delivered-banner ids the Mac reports as handled. + public let handledIDs: [String] + /// The Mac's authoritative unread-notification count, when sent. + public let unreadCount: Int? + + private enum CodingKeys: String, CodingKey { + case handledIDs = "handled_ids" + case unreadCount = "unread_count" + } + + /// Decodes the RPC result, trimming and dropping blank ids and tolerating + /// an absent count. + /// - Parameter decoder: The JSON decoder for the result payload. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawIDs = try container.decodeIfPresent([String].self, forKey: .handledIDs) ?? [] + handledIDs = rawIDs + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + unreadCount = try container.decodeIfPresent(Int.self, forKey: .unreadCount) + } + + /// Decode a reconcile response from the raw RPC result payload. + /// - Parameter data: The RPC result JSON. + /// - Returns: The decoded response. + /// - Throws: A decoding error if the payload is not a JSON object. + public static func decode(_ data: Data) throws -> MobileNotificationReconcileResponse { + try JSONDecoder().decode(Self.self, from: data) + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift new file mode 100644 index 00000000000..3960e4c57b1 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift @@ -0,0 +1,51 @@ +public import Foundation + +/// The system-notification surface for cross-device dismiss-sync: clearing +/// already-delivered banners, enumerating them for the reconcile sweep, and +/// setting the app-icon badge. +/// +/// A seam over `UNUserNotificationCenter` so ``MobileShellComposite`` can react +/// to Mac-side `notification.dismissed` / `notification.badge` events and run +/// the foreground reconcile without hardcoding the +/// `UNUserNotificationCenter.current()` singleton. The production conformance is +/// ``SystemDeliveredNotificationClearer``; tests inject a fake to assert which +/// ids were cleared and which badge counts were applied. +/// +/// All ids at this seam are STABLE MAC-SIDE NOTIFICATION IDS (the +/// `cmux.notificationId` payload key the Mac stamps on every forwarded +/// banner), never raw `UNNotificationRequest` identifiers: a delivered remote +/// notification's request identifier equals the `apns-collapse-id` only by +/// observed OS behavior, not by documented contract, so conformers map between +/// the two themselves (see +/// ``SystemDeliveredNotificationClearer/macNotificationID(for:)``). +public protocol DeliveredNotificationClearing: Sendable { + /// Remove the delivered notifications carrying the given Mac notification + /// ids, if present. Awaitable so a background push wake can finish the + /// removal BEFORE reporting completion to iOS — returning early lets the + /// system suspend the process with the work undone. + /// - Parameter ids: The stable Mac-side notification ids to clear. + func removeDelivered(ids: [String]) async + + /// The Mac notification ids of all currently delivered notifications, for + /// the foreground reconcile sweep. + func deliveredIdentifiers() async -> [String] + + /// SET the app-icon badge to the authoritative unread total computed by the + /// Mac. Always an absolute value — never local +/-1 arithmetic — so any + /// drift self-heals on the next event/push/reconcile. + /// - Parameter count: The unread total; clamped to zero by conformers. + func setBadgeCount(_ count: Int) +} + +/// No-op ``DeliveredNotificationClearing`` for preview stores. +/// +/// A preview/test store must never mutate the real system notification +/// center or app badge, and `UNUserNotificationCenter.current()` traps in +/// processes without a bundle proxy (e.g. `swift test`), so +/// ``MobileShellComposite/preview(runtime:)`` injects this instead of +/// ``SystemDeliveredNotificationClearer``. +struct NoopDeliveredNotificationClearer: DeliveredNotificationClearing { + func removeDelivered(ids: [String]) async {} + func deliveredIdentifiers() async -> [String] { [] } + func setBadgeCount(_ count: Int) {} +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift new file mode 100644 index 00000000000..f8c9f74f60e --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift @@ -0,0 +1,90 @@ +internal import CmuxMobileDiagnostics +internal import CmuxMobileRPC +internal import Foundation +internal import OSLog + +private let mobileShellLog = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios", + category: "mobile-shell" +) + +extension MobileShellComposite { + public func dismissNotification(ids: [String]) async { + let trimmed = ids + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return } + pendingDismissQueue.enqueue(trimmed) + guard let client = remoteClient else { return } + do { + let request = try MobileCoreRPCClient.requestData( + method: "notification.dismiss", + params: [ + "notification_ids": trimmed, + "client_id": clientID, + ] + ) + _ = try await client.sendRequest(request) + pendingDismissQueue.remove(trimmed) + } catch { + mobileShellLog.error("notification dismiss sync failed count=\(trimmed.count, privacy: .public) error=\(String(describing: error), privacy: .public)") + } + } + + func flushPendingNotificationDismisses() async { + let pending = pendingDismissQueue.pendingIDs + guard !pending.isEmpty else { return } + await dismissNotification(ids: pending) + } + + public func clearDeliveredNotifications(ids: [String]) async { + let trimmed = ids + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return } + await deliveredNotificationClearer.removeDelivered(ids: trimmed) + } + + public func applyAuthoritativeUnreadBadge(_ count: Int) { + deliveredNotificationClearer.setBadgeCount(max(0, count)) + } + + func scheduleNotificationReconcile(client: MobileCoreRPCClient) { + Task { [weak self] in + await self?.flushPendingNotificationDismisses() + await self?.reconcileNotificationsWithMac(client: client) + } + } + + func reconcileNotificationsWithMac(client: MobileCoreRPCClient) async { + let deliveredIDs = await deliveredNotificationClearer.deliveredIdentifiers() + guard remoteClient === client, connectionState == .connected else { return } + do { + let request = try MobileCoreRPCClient.requestData( + method: "notification.reconcile", + params: [ + "delivered_ids": deliveredIDs, + "client_id": clientID, + ] + ) + let data = try await client.sendRequest(request) + guard remoteClient === client else { return } + let response = try MobileNotificationReconcileResponse.decode(data) + await applyNotificationReconcile(response) + MobileDebugLog.anchormux( + "notif.reconcile delivered=\(deliveredIDs.count) handled=\(response.handledIDs.count) unread=\(response.unreadCount.map(String.init) ?? "nil")" + ) + } catch { + MobileDebugLog.anchormux("notif.reconcile_failed error=\(error)") + } + } + + func applyNotificationReconcile(_ response: MobileNotificationReconcileResponse) async { + if !response.handledIDs.isEmpty { + await clearDeliveredNotifications(ids: response.handledIDs) + } + if let unreadCount = response.unreadCount { + applyAuthoritativeUnreadBadge(unreadCount) + } + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index f8d8336ded1..e9af2bd2cd7 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -406,6 +406,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private let deviceRegistry: (any DeviceRegistryRefreshing)? private let identityProvider: (any MobileIdentityProviding)? private let reachability: any ReachabilityProviding + // Internal (not private): used by the dismiss-sync extension file. + let deliveredNotificationClearer: any DeliveredNotificationClearing + /// Durable outbox for phone→Mac dismissals. + let pendingDismissQueue: PendingNotificationDismissQueue private let pairingHintDefaults: UserDefaults let clientID: String /// Delivers the email path of Send Feedback (`/api/feedback`). `nil` when the @@ -573,6 +577,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { clientIDRepository: MobileClientIDRepository = MobileClientIDRepository(defaults: .standard), identityProvider: (any MobileIdentityProviding)? = nil, reachability: any ReachabilityProviding = ReachabilityService(), + deliveredNotificationClearer: any DeliveredNotificationClearing = SystemDeliveredNotificationClearer(), + pendingDismissQueue: PendingNotificationDismissQueue = PendingNotificationDismissQueue(), pairingHintDefaults: UserDefaults = .standard, analytics: any AnalyticsEmitting = NoopAnalytics(), diagnosticLog: DiagnosticLog? = nil, @@ -586,6 +592,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.deviceRegistry = deviceRegistry self.identityProvider = identityProvider self.reachability = reachability + self.deliveredNotificationClearer = deliveredNotificationClearer + self.pendingDismissQueue = pendingDismissQueue self.pairingHintDefaults = pairingHintDefaults self.analytics = analytics self.diagnosticLog = diagnosticLog @@ -655,7 +663,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } public static func preview(runtime: (any MobileSyncRuntime)? = nil) -> CMUXMobileShellStore { - CMUXMobileShellStore(runtime: runtime, workspaces: PreviewMobileHost.workspaces) + CMUXMobileShellStore( + runtime: runtime, + workspaces: PreviewMobileHost.workspaces, + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) } public func signIn() { @@ -3944,6 +3956,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // pty-tee. This is the compatibility fallback when the Mac // host does not advertise `terminal.render_grid.v1`. self.handleTerminalBytesEvent(event) + } else if event.topic == "notification.dismissed" { + await self.handleNotificationDismissedEvent(event) + } else if event.topic == "notification.badge" { + self.handleNotificationBadgeEvent(event) } } guard let self else { return } @@ -3985,6 +4001,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } self.markMacConnectionHealthy() MobileDebugLog.anchormux("sync.subscribe_ok topics=\(topics.count) transport=\(transport)") + self.scheduleNotificationReconcile(client: client) } } @@ -4567,6 +4584,32 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { deliverTerminalBytes(bytes, surfaceID: renderGrid.surfaceID) } + private func handleNotificationDismissedEvent(_ event: MobileEventEnvelope) async { + guard + let json = event.payloadJSON, + let payload = MobileNotificationDismissedEvent.decode(json) + else { + return + } + if !payload.ids.isEmpty { + await clearDeliveredNotifications(ids: payload.ids) + } + if let unreadCount = payload.unreadCount { + applyAuthoritativeUnreadBadge(unreadCount) + } + } + + private func handleNotificationBadgeEvent(_ event: MobileEventEnvelope) { + guard + let json = event.payloadJSON, + let payload = MobileNotificationBadgeEvent.decode(json), + let unreadCount = payload.unreadCount + else { + return + } + applyAuthoritativeUnreadBadge(unreadCount) + } + private func handleTerminalBytesEvent(_ event: MobileEventEnvelope) { guard let json = event.payloadJSON, diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/PendingNotificationDismissQueue.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/PendingNotificationDismissQueue.swift new file mode 100644 index 00000000000..a0457db9278 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/PendingNotificationDismissQueue.swift @@ -0,0 +1,69 @@ +public import Foundation + +/// Durable outbox for phone→Mac notification dismissals. +/// +/// A banner swipe can arrive when the dismiss cannot be sent: the app may be +/// background-launched from Notification Center before any scene (and therefore +/// any shell store) exists, and even with a store the attach channel is usually +/// down in the background. Dropping the swipe would leave the Mac's banner and +/// unread entry stale forever — nothing reconciles in the iOS→Mac direction. +/// So every phone-side dismiss is enqueued here first and removed only after +/// the `notification.dismiss` RPC succeeds; ``MobileShellComposite`` flushes +/// the queue on every successful (re)subscribe. +/// +/// Backed by `UserDefaults` so ids survive the process being killed after a +/// background wake. Every operation reads and writes the defaults directly +/// (no in-memory copy), so the separate instances owned by the push +/// coordinator and the shell composite stay coherent over the shared storage. +/// Holds opaque notification UUIDs only, never content. `@MainActor` because +/// both writers (push coordinator, shell composite) are main-actor isolated. +@MainActor +public final class PendingNotificationDismissQueue { + private let defaults: UserDefaults + private static let key = "cmux.notifications.pendingMacDismissIds" + /// FIFO bound; a phone cannot meaningfully accumulate more un-synced + /// dismissals than this, and the Mac ignores unknown ids anyway. + private static let capacity = 128 + + /// Creates a queue over the given defaults store. + /// - Parameter defaults: The backing store; `.standard` in the app, a + /// throwaway suite in tests. + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// The ids waiting to be delivered to the Mac, oldest first. + public var pendingIDs: [String] { + defaults.stringArray(forKey: Self.key) ?? [] + } + + /// Add dismissed notification ids to the outbox. Blank ids are dropped, + /// duplicates are kept once, and the oldest entries are evicted past + /// ``capacity``. + public func enqueue(_ ids: [String]) { + let trimmed = ids + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return } + var pending = pendingIDs + for id in trimmed where !pending.contains(id) { + pending.append(id) + } + if pending.count > Self.capacity { + pending.removeFirst(pending.count - Self.capacity) + } + defaults.set(pending, forKey: Self.key) + } + + /// Remove ids that were confirmed delivered to the Mac. + public func remove(_ ids: [String]) { + guard !ids.isEmpty else { return } + let removal = Set(ids) + let remaining = pendingIDs.filter { !removal.contains($0) } + if remaining.isEmpty { + defaults.removeObject(forKey: Self.key) + } else { + defaults.set(remaining, forKey: Self.key) + } + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/SystemDeliveredNotificationClearer.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/SystemDeliveredNotificationClearer.swift new file mode 100644 index 00000000000..9bad6371f84 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/SystemDeliveredNotificationClearer.swift @@ -0,0 +1,70 @@ +public import Foundation +internal import UserNotifications + +/// Production ``DeliveredNotificationClearing`` backed by the system +/// `UNUserNotificationCenter`. +/// +/// The seam speaks STABLE MAC-SIDE NOTIFICATION IDS, not raw +/// `UNNotificationRequest` identifiers: a delivered remote notification's +/// request identifier is only the `apns-collapse-id` by observed OS behavior, +/// not a documented contract, so every operation maps through the +/// authoritative `cmux.notificationId` payload key (with the request +/// identifier as fallback for pushes that predate the key). Clearing and the +/// delivered-id read are awaited (a background push wake must finish removal +/// before reporting completion to iOS); the badge write is best-effort +/// fire-and-forget. This is the default the app composition root supplies to +/// ``MobileShellComposite``. +public struct SystemDeliveredNotificationClearer: DeliveredNotificationClearing { + /// Creates a clearer over the shared notification center. + public init() {} + + /// Remove the delivered banners carrying the given Mac notification ids. + /// - Parameter ids: The stable Mac-side notification ids to clear. + public func removeDelivered(ids: [String]) async { + guard !ids.isEmpty else { return } + let targets = Set(ids) + // Resolve the Mac ids to the actual delivered request identifiers + // first, because removeDeliveredNotifications matches only on request + // identifiers. Awaited (not fire-and-forget) so a background push wake + // cannot report completion to iOS before the removal ran. + let center = UNUserNotificationCenter.current() + let matching = await center.deliveredNotifications() + .filter { targets.contains(Self.macNotificationID(for: $0.request)) } + .map(\.request.identifier) + guard !matching.isEmpty else { return } + center.removeDeliveredNotifications(withIdentifiers: matching) + } + + /// The Mac notification ids of every currently delivered banner, for the + /// reconcile sweep. + /// - Returns: One id per delivered notification (see ``macNotificationID(for:)``). + public func deliveredIdentifiers() async -> [String] { + await UNUserNotificationCenter.current() + .deliveredNotifications() + .map { Self.macNotificationID(for: $0.request) } + } + + /// SET the app-icon badge to the Mac's authoritative unread total. + /// - Parameter count: The unread total; clamped to zero. + public func setBadgeCount(_ count: Int) { + // Fire-and-forget: a badge write failure (no authorization yet) is + // non-fatal and the next event/push/reconcile sets the total again. + UNUserNotificationCenter.current().setBadgeCount(max(0, count), withCompletionHandler: nil) + } + + /// The stable Mac-side notification id for a delivered banner: the + /// `cmux.notificationId` payload key when present (authoritative — the Mac + /// stamps it on every forwarded banner), else the request identifier. The + /// fallback keeps older deliveries reconcilable when their request + /// identifier happens to be the collapse-id, and is harmless otherwise: an + /// OS-assigned random identifier matches no Mac notification, so the Mac + /// never classifies it as handled and the banner is left alone. + static func macNotificationID(for request: UNNotificationRequest) -> String { + if let cmux = request.content.userInfo["cmux"] as? [String: Any], + let id = (cmux["notificationId"] as? String)?.trimmingCharacters(in: .whitespaces), + !id.isEmpty { + return id + } + return request.identifier + } +} diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift new file mode 100644 index 00000000000..8a9bd08e3a8 --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift @@ -0,0 +1,210 @@ +import CMUXMobileCore +import CmuxMobileRPC +import CmuxMobileShellModel +import Foundation +import Testing +import UserNotifications +@testable import CmuxMobileShell + +@MainActor +private final class RecordingDeliveredNotificationClearer: DeliveredNotificationClearing { + private(set) var clearedIDs: [[String]] = [] + private(set) var badgeCounts: [Int] = [] + var deliveredIDs: [String] = [] + + nonisolated init() {} + + nonisolated func removeDelivered(ids: [String]) async { + await MainActor.run { + clearedIDs.append(ids) + } + } + + nonisolated func deliveredIdentifiers() async -> [String] { + await MainActor.run { deliveredIDs } + } + + nonisolated func setBadgeCount(_ count: Int) { + MainActor.assumeIsolated { + badgeCounts.append(count) + } + } +} + +@MainActor +@Suite struct MobileShellDismissSyncTests { + private func makeStore( + clearer: any DeliveredNotificationClearing, + pendingDismissQueue: PendingNotificationDismissQueue = + PendingNotificationDismissQueue(defaults: UserDefaults(suiteName: "dismiss-queue-\(UUID().uuidString)")!) + ) -> MobileShellComposite { + MobileShellComposite( + workspaces: [], + deliveredNotificationClearer: clearer, + pendingDismissQueue: pendingDismissQueue, + pairingHintDefaults: UserDefaults(suiteName: "dismiss-sync-\(UUID().uuidString)")! + ) + } + + @Test func clearsDeliveredBannersForDismissedIDs() async { + let clearer = RecordingDeliveredNotificationClearer() + let store = makeStore(clearer: clearer) + + await store.clearDeliveredNotifications(ids: ["n-1", "n-2"]) + + #expect(clearer.clearedIDs == [["n-1", "n-2"]]) + } + + @Test func trimsAndDropsBlankIDsBeforeClearing() async { + let clearer = RecordingDeliveredNotificationClearer() + let store = makeStore(clearer: clearer) + + await store.clearDeliveredNotifications(ids: [" n-3 ", "", " "]) + + #expect(clearer.clearedIDs == [["n-3"]]) + } + + @Test func noOpsWhenNoUsableIDs() async { + let clearer = RecordingDeliveredNotificationClearer() + let store = makeStore(clearer: clearer) + + await store.clearDeliveredNotifications(ids: ["", " "]) + + #expect(clearer.clearedIDs.isEmpty) + } + + @Test func dismissWithoutChannelParksIDsInDurableOutbox() async { + let queue = PendingNotificationDismissQueue( + defaults: UserDefaults(suiteName: "dismiss-queue-\(UUID().uuidString)")! + ) + let store = makeStore( + clearer: RecordingDeliveredNotificationClearer(), + pendingDismissQueue: queue + ) + + await store.dismissNotification(ids: [" n-1 ", "", "n-2"]) + + #expect(queue.pendingIDs == ["n-1", "n-2"]) + } + + @Test func dismissWithNoUsableIDsLeavesOutboxEmpty() async { + let queue = PendingNotificationDismissQueue( + defaults: UserDefaults(suiteName: "dismiss-queue-\(UUID().uuidString)")! + ) + let store = makeStore( + clearer: RecordingDeliveredNotificationClearer(), + pendingDismissQueue: queue + ) + + await store.dismissNotification(ids: ["", " "]) + + #expect(queue.pendingIDs.isEmpty) + } + + @Test func setsBadgeToAuthoritativeTotal() { + let clearer = RecordingDeliveredNotificationClearer() + let store = makeStore(clearer: clearer) + + store.applyAuthoritativeUnreadBadge(7) + store.applyAuthoritativeUnreadBadge(0) + + #expect(clearer.badgeCounts == [7, 0]) + } + + @Test func clampsNegativeBadgeToZero() { + let clearer = RecordingDeliveredNotificationClearer() + let store = makeStore(clearer: clearer) + + store.applyAuthoritativeUnreadBadge(-3) + + #expect(clearer.badgeCounts == [0]) + } + + @Test func reconcileClearsHandledBannersAndSetsBadge() async throws { + let clearer = RecordingDeliveredNotificationClearer() + let store = makeStore(clearer: clearer) + let response = try MobileNotificationReconcileResponse.decode(Data(""" + {"handled_ids": ["n-1", "n-3"], "unread_count": 2} + """.utf8)) + + await store.applyNotificationReconcile(response) + + #expect(clearer.clearedIDs == [["n-1", "n-3"]]) + #expect(clearer.badgeCounts == [2]) + } + + @Test func reconcileWithNothingHandledOnlySetsBadge() async throws { + let clearer = RecordingDeliveredNotificationClearer() + let store = makeStore(clearer: clearer) + let response = try MobileNotificationReconcileResponse.decode(Data(""" + {"handled_ids": [], "unread_count": 0} + """.utf8)) + + await store.applyNotificationReconcile(response) + + #expect(clearer.clearedIDs.isEmpty) + #expect(clearer.badgeCounts == [0]) + } + + @Test func reconcileFromOlderMacWithoutCountLeavesBadgeAlone() async throws { + let clearer = RecordingDeliveredNotificationClearer() + let store = makeStore(clearer: clearer) + let response = try MobileNotificationReconcileResponse.decode(Data(""" + {"handled_ids": ["n-9"]} + """.utf8)) + + await store.applyNotificationReconcile(response) + + #expect(clearer.clearedIDs == [["n-9"]]) + #expect(clearer.badgeCounts.isEmpty) + } + + @Test func macNotificationIDPrefersPayloadKeyOverRequestIdentifier() { + let content = UNMutableNotificationContent() + content.userInfo = ["cmux": ["notificationId": " mac-id-1 "]] + let request = UNNotificationRequest( + identifier: "os-assigned-identifier", + content: content, + trigger: nil + ) + + #expect(SystemDeliveredNotificationClearer.macNotificationID(for: request) == "mac-id-1") + } + + @Test func macNotificationIDFallsBackToRequestIdentifier() { + let content = UNMutableNotificationContent() + let request = UNNotificationRequest( + identifier: "legacy-collapse-id", + content: content, + trigger: nil + ) + + #expect(SystemDeliveredNotificationClearer.macNotificationID(for: request) == "legacy-collapse-id") + } + + @Test func dismissedEventDecodesUnreadCount() { + let event = MobileNotificationDismissedEvent.decode(Data(""" + {"ids": ["a", " b "], "unread_count": 4} + """.utf8)) + + #expect(event?.ids == ["a", "b"]) + #expect(event?.unreadCount == 4) + } + + @Test func dismissedEventToleratesMissingUnreadCount() { + let event = MobileNotificationDismissedEvent.decode(Data(""" + {"ids": ["a"]} + """.utf8)) + + #expect(event?.ids == ["a"]) + #expect(event?.unreadCount == nil) + } + + @Test func badgeEventDecodesUnreadCount() { + let event = MobileNotificationBadgeEvent.decode(Data(""" + {"unread_count": 12} + """.utf8)) + + #expect(event?.unreadCount == 12) + } +} diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/PendingNotificationDismissQueueTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/PendingNotificationDismissQueueTests.swift new file mode 100644 index 00000000000..cafbdae498c --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/PendingNotificationDismissQueueTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +@testable import CmuxMobileShell + +@MainActor +@Suite struct PendingNotificationDismissQueueTests { + private func makeDefaults() -> UserDefaults { + UserDefaults(suiteName: "dismiss-queue-\(UUID().uuidString)")! + } + + @Test func enqueueTrimsAndDropsBlankIDs() { + let queue = PendingNotificationDismissQueue(defaults: makeDefaults()) + + queue.enqueue([" n-1 ", "", " ", "n-2"]) + + #expect(queue.pendingIDs == ["n-1", "n-2"]) + } + + @Test func enqueueKeepsDuplicatesOnceAndPreservesOrder() { + let queue = PendingNotificationDismissQueue(defaults: makeDefaults()) + + queue.enqueue(["a", "b"]) + queue.enqueue(["b", "c", "a"]) + + #expect(queue.pendingIDs == ["a", "b", "c"]) + } + + @Test func removeDropsOnlyConfirmedIDs() { + let queue = PendingNotificationDismissQueue(defaults: makeDefaults()) + queue.enqueue(["a", "b", "c"]) + + queue.remove(["b", "missing"]) + + #expect(queue.pendingIDs == ["a", "c"]) + } + + @Test func removingEverythingClearsTheBackingKey() { + let defaults = makeDefaults() + let queue = PendingNotificationDismissQueue(defaults: defaults) + queue.enqueue(["a"]) + + queue.remove(["a"]) + + #expect(queue.pendingIDs.isEmpty) + #expect(defaults.object(forKey: "cmux.notifications.pendingMacDismissIds") == nil) + } + + @Test func capacityEvictsOldestFirst() { + let queue = PendingNotificationDismissQueue(defaults: makeDefaults()) + + queue.enqueue((0..<130).map { "n-\($0)" }) + + let pending = queue.pendingIDs + #expect(pending.count == 128) + #expect(pending.first == "n-2") + #expect(pending.last == "n-129") + } + + @Test func separateInstancesStayCoherentOverSharedDefaults() { + let defaults = makeDefaults() + let coordinatorSide = PendingNotificationDismissQueue(defaults: defaults) + let compositeSide = PendingNotificationDismissQueue(defaults: defaults) + + coordinatorSide.enqueue(["n-1"]) + #expect(compositeSide.pendingIDs == ["n-1"]) + + compositeSide.remove(["n-1"]) + #expect(coordinatorSide.pendingIDs.isEmpty) + } +} From 4be32ebff2c7eceb799d1f957615303239163ed2 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:28:04 -0700 Subject: [PATCH 19/31] Restore Mac mobile notification sync handlers --- Sources/Cloud/PhonePushClient.swift | 146 ++++++- .../MobileHostService+Capabilities.swift | 3 + Sources/SupersededPhoneDismissBuffer.swift | 60 +++ ...nalController+MobileNotificationSync.swift | 106 +++++ Sources/TerminalController.swift | 20 +- Sources/TerminalNotificationStore.swift | 295 ++++++++++++- cmux.xcodeproj/project.pbxproj | 20 +- cmuxTests/NotificationDismissSyncTests.swift | 394 ++++++++++++++++++ 8 files changed, 1010 insertions(+), 34 deletions(-) create mode 100644 Sources/SupersededPhoneDismissBuffer.swift create mode 100644 Sources/TerminalController+MobileNotificationSync.swift create mode 100644 cmuxTests/NotificationDismissSyncTests.swift diff --git a/Sources/Cloud/PhonePushClient.swift b/Sources/Cloud/PhonePushClient.swift index 4249a4e2ef5..a1578d2e13b 100644 --- a/Sources/Cloud/PhonePushClient.swift +++ b/Sources/Cloud/PhonePushClient.swift @@ -20,6 +20,11 @@ enum PhonePushSettings { /// by ``PhonePushSettings/forwardEnabledKey`` (off by default) and only invoked /// from the not-suppressed desktop-delivery path, so it mirrors what the Mac /// itself shows. Best-effort and non-blocking. +/// +/// Two push kinds share the transport: the visible banner mirror +/// (``forward(_:badgeCount:)``) and the silent dismiss/badge push +/// (``forwardDismissed(ids:badgeCount:)``), the cold lane of Mac→iOS +/// dismiss-sync that reaches every registered device, attached or not. @MainActor final class PhonePushClient { static let shared = PhonePushClient() @@ -37,6 +42,19 @@ final class PhonePushClient { /// see `MacPresenceDecisionCache` for the staleness invariant. private var presenceCache = MacPresenceDecisionCache() + /// Dismissed ids waiting for the drain task, deduped at send time. Bursts + /// coalesce structurally: ids accumulate while one send is in flight and the + /// drain loop ships whatever piled up next, chunked to the server's cap, so + /// "Clear All" (one emit) is one push and N rapid swipes are at most a + /// couple — no timers involved. + private var pendingDismissedIDs: [String] = [] + /// The freshest authoritative unread count; later dismisses overwrite it so + /// the badge that ships is always the latest total. + private var pendingDismissBadgeCount = 0 + private var dismissDrainTask: Task? + /// Keep each dismiss push within the server's `MAX_PUSH_DISMISS_IDS`. + private static let maxDismissIDsPerPush = 64 + private init() {} /// Inject the auth dependency. Call once at the composition root. @@ -65,17 +83,46 @@ final class PhonePushClient { } } + /// Whether a banner forward for this delivery would currently pass the + /// enable + presence gate, ignoring the per-tab/surface burst throttle. + /// + /// The superseded-banner buffering decision in + /// ``TerminalNotificationStore`` keys on this so it matches the real send + /// decision in ``forward(_:badgeCount:)``: only the throttle is a + /// legitimate "defer and flush on the next successful forward" case. When + /// forwarding is off or the `.onlyWhenAway` presence gate suppresses the + /// replacement (Mac active), no replacement push is coming, so the store + /// must emit the superseded dismiss immediately rather than stash it for a + /// forward that will never happen. Returns `false` when forwarding is off + /// or the presence gate currently suppresses delivery; `true` otherwise. + func willForwardReplacement(defaults: UserDefaults = .standard) -> Bool { + guard defaults.bool(forKey: PhonePushSettings.forwardEnabledKey) else { return false } + let mode = PhoneForwardingMode.fromDefaults(defaults) + if mode == .always { return true } + let presence = presenceCache.decision(from: presenceMonitor) + return Self.shouldForward(mode: mode, presence: presence) + } + /// Forward a notification if the user opted in. Captures the fields up front /// and performs the network call off the caller's critical path. - func forward(_ notification: TerminalNotification) { - guard Self.isForwardingEnabled else { return } + /// - Parameter badgeCount: The authoritative unread-notification total at + /// send time; the server emits it as `aps.badge` so the phone's icon badge + /// is always SET to the computed total (never incremented locally). + /// - Returns: Whether the banner push was actually queued for sending — + /// `false` when forwarding is off or the per-tab/surface throttle dropped + /// it. The store keys the superseded-banner dismiss on this, so a + /// throttled replacement can never strand the phone with no banner for a + /// still-unread notification. + @discardableResult + func forward(_ notification: TerminalNotification, badgeCount: Int) -> Bool { + guard Self.isForwardingEnabled else { return false } // Read-only burst-throttle check FIRST: a dictionary lookup that // bounds everything downstream (presence sampling and sends) to one // per key per second under notification storms. let key = "\(notification.tabId.uuidString):\(notification.surfaceId?.uuidString ?? "")" let now = Date() - if let last = lastSentAt[key], now.timeIntervalSince(last) < Self.minInterval { return } + if let last = lastSentAt[key], now.timeIntervalSince(last) < Self.minInterval { return false } // Presence gate, decided per notification at delivery time so the // phone never receives a suppressed push. `.always` skips sampling @@ -85,6 +132,9 @@ final class PhonePushClient { // by the active-decision cache instead of the send throttle, because // suppression must not consume a send slot. See // `MacPresenceDecisionCache` for the explicit staleness invariant. + // A suppressed forward queues no banner push, so it reports `false` + // like a throttled one: the store must not dismiss the superseded + // banner when no replacement is coming. let mode = PhoneForwardingMode.fromDefaults() if mode != .always { let presence = presenceCache.decision(from: presenceMonitor) @@ -92,7 +142,7 @@ final class PhonePushClient { #if DEBUG cmuxDebugLog("phonepush.suppressed reason=macActive verdict=\(presence.verdict)") #endif - return + return false } } @@ -103,22 +153,85 @@ final class PhonePushClient { let hideContent = UserDefaults.standard.bool(forKey: PhonePushSettings.hideContentKey) let payload = Payload( + kind: .notify, title: notification.title, subtitle: notification.subtitle, body: notification.body, workspaceId: notification.tabId.uuidString, surfaceId: notification.surfaceId?.uuidString, + notificationId: notification.id.uuidString, + notificationIds: [], + badgeCount: badgeCount, hideContent: hideContent ) Task { await send(payload) } + return true + } + + /// The cold lane of Mac→iOS dismiss-sync: mirror a Mac-side dismiss through + /// a silent APNs push (`content-available` + `aps.badge` + the dismissed + /// ids). Sent unconditionally — the push route fans out to every registered + /// device token, so a live-attached phone (which already handled the peer + /// event; the push is an idempotent no-op there) must not starve an offline + /// second device. + /// The system applies the badge immediately; banner removal happens when iOS + /// grants the (strictly budgeted) background wake, and the app-foreground + /// reconcile sweep heals anything iOS deferred. Carries only opaque UUIDs. + func forwardDismissed(ids: [String], badgeCount: Int) { + guard Self.isForwardingEnabled, !ids.isEmpty else { return } + pendingDismissedIDs.append(contentsOf: ids) + pendingDismissBadgeCount = badgeCount + guard dismissDrainTask == nil else { return } + dismissDrainTask = Task { [weak self] in + await self?.drainPendingDismisses() + } + } + + private func drainPendingDismisses() async { + defer { dismissDrainTask = nil } + while !pendingDismissedIDs.isEmpty { + var seen = Set() + let deduped = pendingDismissedIDs.filter { seen.insert($0).inserted } + let chunk = Array(deduped.prefix(Self.maxDismissIDsPerPush)) + pendingDismissedIDs = Array(deduped.dropFirst(Self.maxDismissIDsPerPush)) + await send(Payload( + kind: .dismiss, + title: "", + subtitle: "", + body: "", + workspaceId: nil, + surfaceId: nil, + notificationId: nil, + notificationIds: chunk, + badgeCount: pendingDismissBadgeCount, + hideContent: false + )) + } } private struct Payload: Sendable { + enum Kind: String, Sendable { + /// Visible banner mirror of a Mac notification. + case notify + /// Silent banner-removal + badge push (Mac-side dismiss, cold lane). + case dismiss + } + + let kind: Kind let title: String let subtitle: String let body: String - let workspaceId: String + let workspaceId: String? let surfaceId: String? + /// Stable notification id (the Mac store ``TerminalNotification/id``). + /// Travels to APNs as both an `apns-collapse-id` (so a later Mac→iOS + /// dismiss can target the delivered banner) and `cmux.notificationId` + /// (so an iOS swipe can tell the Mac which notification was dismissed). + let notificationId: String? + /// The dismissed ids a `.dismiss` push carries (else empty). + let notificationIds: [String] + /// Authoritative unread total at send time, emitted as `aps.badge`. + let badgeCount: Int let hideContent: Bool } @@ -141,15 +254,24 @@ final class PhonePushClient { // When hideContent is on, the real terminal title/subtitle/body must // never leave the Mac. Send generic placeholders so the request still // carries valid, parseable fields while the actual content stays local. - // workspaceId/surfaceId/hideContent are opaque IDs/flags, not content. + // Ids, the badge count, and hideContent are opaque values, not content. var bodyDict: [String: Any] = [ - "title": payload.hideContent ? "cmux" : payload.title, - "subtitle": payload.hideContent ? "" : payload.subtitle, - "body": payload.hideContent ? "New terminal activity" : payload.body, - "workspaceId": payload.workspaceId, + "kind": payload.kind.rawValue, + "badgeCount": payload.badgeCount, "hideContent": payload.hideContent, ] - if let surfaceId = payload.surfaceId { bodyDict["surfaceId"] = surfaceId } + switch payload.kind { + case .notify: + bodyDict["title"] = payload.hideContent ? "cmux" : payload.title + bodyDict["subtitle"] = payload.hideContent ? "" : payload.subtitle + bodyDict["body"] = payload.hideContent ? "New terminal activity" : payload.body + if let workspaceId = payload.workspaceId { bodyDict["workspaceId"] = workspaceId } + if let surfaceId = payload.surfaceId { bodyDict["surfaceId"] = surfaceId } + // Opaque UUID, not content: safe to send even when hideContent is on. + if let notificationId = payload.notificationId { bodyDict["notificationId"] = notificationId } + case .dismiss: + bodyDict["notificationIds"] = payload.notificationIds + } var req = URLRequest(url: url) req.httpMethod = "POST" @@ -165,7 +287,7 @@ final class PhonePushClient { do { let (_, response) = try await session.data(for: req) if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { - NSLog("cmux.phonepush failed status=%d", http.statusCode) + NSLog("cmux.phonepush failed kind=%@ status=%d", payload.kind.rawValue, http.statusCode) } } catch { // best-effort; phone forwarding must never disrupt the Mac. diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index 1e2da4f928e..d3ecf136d7e 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -16,6 +16,9 @@ extension MobileHostService { nonisolated static var mobileHostCapabilities: [String] { [ "events.v1", + "notification.badge.v1", + "notification.dismiss.v1", + "notification.reconcile.v1", "terminal.bytes.v1", "terminal.render_grid.v1", "terminal.replay.v1", diff --git a/Sources/SupersededPhoneDismissBuffer.swift b/Sources/SupersededPhoneDismissBuffer.swift new file mode 100644 index 00000000000..a64a359a9a1 --- /dev/null +++ b/Sources/SupersededPhoneDismissBuffer.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Per-tab/surface stash of superseded phone-banner ids whose dismiss is +/// deferred until the replacement banner push is actually queued (the phone +/// push path throttles per tab/surface; see +/// ``TerminalNotificationStore/deliverNotificationSideEffects``). Bounded per +/// key; holds opaque notification UUID strings only, never content. +struct SupersededPhoneDismissBuffer { + private var idsByKey: [String: [String]] = [:] + /// More superseded-but-undelivered banners than this for one tab/surface + /// means a runaway producer; the oldest ids are evicted (their banners were + /// already replaced on the phone by newer ones via earlier dismissals, and + /// the reconcile sweep heals any stragglers from the tombstone ring). + static let capacityPerKey = 64 + + /// The stash key for a notification's tab/surface, mirroring the phone push + /// throttle key. + static func key(tabId: UUID, surfaceId: UUID?) -> String { + "\(tabId.uuidString):\(surfaceId?.uuidString ?? "")" + } + + /// Park superseded banner ids until the replacement push is queued. + /// Duplicates are kept once; the oldest evicted past ``capacityPerKey``. + mutating func stash(ids: [String], forKey key: String) { + guard !ids.isEmpty else { return } + var pending = idsByKey[key] ?? [] + for id in ids where !pending.contains(id) { + pending.append(id) + } + if pending.count > Self.capacityPerKey { + pending.removeFirst(pending.count - Self.capacityPerKey) + } + idsByKey[key] = pending + } + + /// Take (and clear) everything stashed for the key, oldest first. + mutating func flush(forKey key: String) -> [String] { + idsByKey.removeValue(forKey: key) ?? [] + } + + /// Take (and clear) everything stashed under the given tab, for tab-scoped + /// read/clear operations (after which no surface in the tab has an unread + /// entry, so no stale banner may survive). + mutating func flush(matchingTabId tabId: UUID) -> [String] { + let prefix = tabId.uuidString + ":" + var drained: [String] = [] + for key in idsByKey.keys.filter({ $0.hasPrefix(prefix) }).sorted() { + drained.append(contentsOf: idsByKey.removeValue(forKey: key) ?? []) + } + return drained + } + + /// Take (and clear) everything stashed across all keys, for clear-all / + /// mark-all-read operations. + mutating func flushAll() -> [String] { + let drained = idsByKey.keys.sorted().flatMap { idsByKey[$0] ?? [] } + idsByKey.removeAll() + return drained + } +} diff --git a/Sources/TerminalController+MobileNotificationSync.swift b/Sources/TerminalController+MobileNotificationSync.swift new file mode 100644 index 00000000000..42528cb8c65 --- /dev/null +++ b/Sources/TerminalController+MobileNotificationSync.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Mobile-host notification verbs (cross-device dismiss-sync): the +/// `notification.dismiss` and `notification.reconcile` RPC handlers dispatched +/// from `mobileHostHandleRPC(_:)`. +extension TerminalController { + /// Mark notifications read on the Mac in response to the user dismissing the + /// mirrored banner on a paired phone. Accepts either a single `notification_id` + /// or a `notification_ids` array; ignores unknown/malformed ids. + /// + /// Deliberately uses ``TerminalNotificationStore/markRead(id:)`` — NOT + /// `remove` — so it mirrors a Mac banner *swipe* (which the Mac's own + /// `UNUserNotificationCenterDelegate` handles via `markRead`, keeping the + /// entry in the notification list while clearing the banner + unread). This + /// is distinct from the socket `notification.dismiss` verb + /// (``v2NotificationDismiss(params:)``), which fully `remove`s the entry. The + /// resulting `markRead` emits `notification.dismissed` back, a harmless no-op + /// for the already-removed phone banner. Carries only opaque UUIDs, never + /// terminal content. + func v2MobileNotificationDismiss(params: [String: Any]) -> V2CallResult { + // Cap the scan like `notification.reconcile`: a phone cannot meaningfully + // dismiss more than this in one request (its durable outbox holds 128), + // so anything past the cap is a malformed or hostile frame and is + // ignored instead of trimmed/parsed on the main actor. + let maxDismissIDs = 256 + var rawIDs: [String] = [] + if let single = v2OptionalTrimmedRawString(params, "notification_id") { + rawIDs.append(single) + } + if let array = params["notification_ids"] as? [Any] { + for value in array.prefix(maxDismissIDs) { + if let string = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !string.isEmpty { + rawIDs.append(string) + } + } + } + // Dedupe (preserving order) so a repeated id cannot double-count in + // `dismissed` or run the markRead path twice. + var seenIDs = Set() + let ids = rawIDs + .compactMap { UUID(uuidString: $0) } + .filter { seenIDs.insert($0).inserted } + guard !ids.isEmpty else { + return .err( + code: "invalid_params", + message: "Missing or invalid notification_id / notification_ids", + data: nil + ) + } + let store = TerminalNotificationStore.shared + // `dismissed` counts notifications that actually transitioned unread→read, + // not the number of ids supplied: unknown or already-read ids are no-ops, + // so a stale/duplicate phone dismiss reports 0 rather than a misleading hit. + let unreadIDs = Set(store.notifications.filter { !$0.isRead }.map(\.id)) + var dismissed = 0 + for id in ids where unreadIDs.contains(id) { + store.markRead(id: id) + dismissed += 1 + } + return .ok(["dismissed": dismissed]) + } + + /// Foreground reconcile sweep for the phone (lane 3 of dismiss-sync): given + /// the banner ids currently delivered on the phone, report which were handled + /// on this Mac — read in the store, or recently dismissed/removed + /// (tombstoned) — plus the authoritative unread count, so the phone clears + /// stale banners and SETS its icon badge to the computed total. Ids unknown + /// to this Mac are not reported handled (they may belong to a different + /// paired Mac). An empty `delivered_ids` is a valid badge-only sync. + /// Exchanges only opaque UUIDs and a count, never terminal content. + func v2MobileNotificationReconcile(params: [String: Any]) -> V2CallResult { + // Cap the scan: iOS keeps only the most recent delivered notifications, + // so anything past this is a malformed or hostile request. + let maxDeliveredIDs = 256 + let rawIDs = ((params["delivered_ids"] as? [Any]) ?? []).prefix(maxDeliveredIDs) + let deliveredIDs = rawIDs.compactMap { value -> UUID? in + guard let string = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !string.isEmpty else { + return nil + } + return UUID(uuidString: string) + } + let store = TerminalNotificationStore.shared + return .ok([ + "handled_ids": store.reconcileHandledNotificationIDs(deliveredIDs: deliveredIDs), + "unread_count": store.unreadNotificationCount, + ]) + } + + /// The `workspace.action` sub-actions the mobile data plane may invoke. + /// + /// Mobile gets pin/unpin/rename/read-state only. The other sub-actions of + /// ``v2WorkspaceAction(params:)`` reorder the global sidebar or destroy + /// sibling workspaces, so they stay on the Mac/automation socket. The action + /// is normalized exactly as ``v2ActionKey(_:_:)`` so this gate and the + /// handler can never disagree on which action runs. + /// - Parameter rawAction: The raw `action` param value. + /// - Returns: `true` when the normalized action is mobile-allowed. + nonisolated static func mobileAllowsWorkspaceAction(_ rawAction: String?) -> Bool { + guard let trimmed = rawAction?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return false } + let normalized = trimmed.lowercased().replacingOccurrences(of: "-", with: "_") + return ["pin", "unpin", "rename", "mark_read", "mark_unread"].contains(normalized) + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0c63f1ec9ea..a1e15438231 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13391,6 +13391,10 @@ class TerminalController { result = v2MobileWorkspaceGroupSetCollapsed(params: request.params, isCollapsed: true) case "workspace.group.expand": result = v2MobileWorkspaceGroupSetCollapsed(params: request.params, isCollapsed: false) + case "notification.dismiss": + result = v2MobileNotificationDismiss(params: request.params) + case "notification.reconcile": + result = v2MobileNotificationReconcile(params: request.params) case "dogfood.feedback.submit": result = await v2MobileDogfoodFeedbackSubmit(params: request.params) default: @@ -13605,22 +13609,6 @@ class TerminalController { } } - /// The `workspace.action` sub-actions the mobile data plane may invoke. - /// - /// Mobile gets pin/unpin/rename/read-state only. The other sub-actions of - /// ``v2WorkspaceAction(params:)`` reorder the global sidebar or destroy - /// sibling workspaces, so they stay on the Mac/automation socket. The action - /// is normalized exactly as ``v2ActionKey(_:_:)`` so this gate and the - /// handler can never disagree on which action runs. - /// - Parameter rawAction: The raw `action` param value. - /// - Returns: `true` when the normalized action is mobile-allowed. - nonisolated static func mobileAllowsWorkspaceAction(_ rawAction: String?) -> Bool { - guard let trimmed = rawAction?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty else { return false } - let normalized = trimmed.lowercased().replacingOccurrences(of: "-", with: "_") - return ["pin", "unpin", "rename", "mark_read", "mark_unread"].contains(normalized) - } - /// Mobile-gated wrapper over ``v2WorkspaceAction(params:)``. private func v2MobileWorkspaceAction(params: [String: Any]) -> V2CallResult { let rawAction = v2RawString(params, "action") diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 48c5079f966..e36d1336a40 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -238,6 +238,186 @@ final class TerminalNotificationStore: ObservableObject { static let categoryIdentifier = "com.cmuxterm.app.userNotification" static let actionShowIdentifier = "com.cmuxterm.app.userNotification.show" + + /// Mobile-host event topic the Mac emits when one or more delivered + /// notifications are dismissed/cleared on this Mac, so an attached phone can + /// clear the matching banners it is mirroring. Payload carries the stable + /// notification ids plus the authoritative unread count + /// (`["ids": [String], "unread_count": Int]`) — never any terminal content — + /// so dismiss-sync is safe even with phone-forward hideContent on. + static let dismissedEventTopic = "notification.dismissed" + + /// Mobile-host event topic carrying the authoritative unread-notification + /// count (`["unread_count": Int]`) whenever it changes. The phone SETS its + /// app-icon badge to this absolute total (never local ±1 arithmetic), so any + /// drift self-heals on the next event. Emitted from the same chokepoint that + /// refreshes the Mac Dock badge, so every mutation lane is covered. + static let badgeEventTopic = "notification.badge" + + /// The number of unread notification *entries* — the count the iOS app icon + /// badge mirrors. The phone's banners mirror notification entries, so its + /// badge counts exactly those. (The Mac Dock badge additionally counts + /// workspace-level manual unread indicators, which have no phone banner.) + var unreadNotificationCount: Int { indexes.unreadCount } + + /// Recently dismissed/cleared notification ids, kept so the phone's + /// foreground reconcile sweep can classify a delivered banner as "handled + /// here" even after the entry left the store entirely (remove / clear-all + /// paths). Bounded ring: oldest evicted past ``dismissedTombstoneCapacity``. + /// Holds opaque UUIDs only, never content. + /// + /// Write-through persisted to `UserDefaults` (lazy-loaded on first use) so + /// the reconcile lane survives a Mac relaunch: session restore keeps + /// notification ids stable, so a phone that reconnects after this app + /// restarted must still learn that a banner it holds was dismissed here + /// even when the silent dismiss push never reached it. + private var dismissedTombstoneIDs = Set() + private var dismissedTombstoneOrder: [UUID] = [] + private var dismissedTombstonesLoaded = false + private static let dismissedTombstoneCapacity = 512 + static let dismissedTombstoneDefaultsKey = "cmux.notifications.dismissedTombstoneIds" + + private func loadDismissedTombstonesIfNeeded() { + guard !dismissedTombstonesLoaded else { return } + dismissedTombstonesLoaded = true + let stored = UserDefaults.standard.stringArray(forKey: Self.dismissedTombstoneDefaultsKey) ?? [] + for id in stored.compactMap({ UUID(uuidString: $0) }) where dismissedTombstoneIDs.insert(id).inserted { + dismissedTombstoneOrder.append(id) + } + } + + private func recordDismissTombstones(ids: [UUID]) { + loadDismissedTombstonesIfNeeded() + for id in ids where dismissedTombstoneIDs.insert(id).inserted { + dismissedTombstoneOrder.append(id) + } + let overflow = dismissedTombstoneOrder.count - Self.dismissedTombstoneCapacity + if overflow > 0 { + for stale in dismissedTombstoneOrder.prefix(overflow) { + dismissedTombstoneIDs.remove(stale) + } + dismissedTombstoneOrder.removeFirst(overflow) + } + UserDefaults.standard.set( + dismissedTombstoneOrder.map(\.uuidString), + forKey: Self.dismissedTombstoneDefaultsKey + ) + } + + /// Drop the in-memory tombstone copy so the next use re-reads the persisted + /// ring — the behavior-test analogue of a process restart. + func reloadDismissedTombstonesForTesting() { + dismissedTombstoneIDs.removeAll() + dismissedTombstoneOrder.removeAll() + dismissedTombstonesLoaded = false + } + + /// Phone-banner dismissals for superseded notifications, deferred until the + /// replacement banner push for the same tab/surface is actually queued. + /// ``PhonePushClient/forward(_:badgeCount:)`` throttles per tab/surface, so + /// dismissing the old banner unconditionally could strand the phone with no + /// banner at all for a still-unread notification when the replacement push + /// was dropped. When a replacement forward is expected, the store stashes + /// the superseded ids here and emits the dismiss only after the push is + /// queued, making clear+replace atomic from the phone's perspective; when + /// no replacement will be forwarded at all, `recordNotification` emits the + /// dismiss immediately instead of stashing. While deferred, the phone keeps + /// the older (stale-text) banner — the pre-existing throttle behavior — and + /// the reconcile sweep still classifies the ids correctly because they are + /// tombstoned at supersede time. + private var supersededPhoneDismissBuffer = SupersededPhoneDismissBuffer() + + /// Classify which of the phone's delivered banner ids have been handled on + /// this Mac: still in the store and read, or recently removed (tombstoned). + /// Ids this Mac has never seen are NOT reported handled — they may belong to + /// a different paired Mac — so the phone leaves those banners alone. An id + /// that is currently unread is never handled, even if an older tombstone + /// exists (markUnread after a dismiss resurrects it). + func reconcileHandledNotificationIDs(deliveredIDs: [UUID]) -> [String] { + guard !deliveredIDs.isEmpty else { return [] } + loadDismissedTombstonesIfNeeded() + var readIDs = Set() + var knownIDs = Set() + for notification in notifications { + knownIDs.insert(notification.id) + if notification.isRead { readIDs.insert(notification.id) } + } + return deliveredIDs + .filter { id in + if knownIDs.contains(id) { return readIDs.contains(id) } + return dismissedTombstoneIDs.contains(id) + } + .map(\.uuidString) + } + + /// Forwards a dismiss/clear to the user's phone. Call only from the + /// change-confirmed branch of a user-driven read/clear/remove path, so the + /// Mac→iOS→Mac echo can't loop. Session restore / surface rebind paths must + /// NOT call this: they reassign ids on churn and would clear a phone banner + /// that should persist. + /// + /// Two lanes share this chokepoint: the instant peer event for a + /// live-attached phone, and a silent APNs badge push (the cold lane) so a + /// pocketed phone still drops the banner and badge. Both carry the + /// authoritative unread count. + /// + /// The cold lane is sent UNCONDITIONALLY (never gated on live subscribers): + /// the push route fans out to every iOS device token registered for the + /// user, so one live-attached phone must not starve an offline second + /// device of its dismiss. The push is idempotent on a device that already + /// handled the live event — removing an already-removed banner is a no-op + /// and the badge is an absolute SET — and bursts coalesce in + /// ``PhonePushClient/forwardDismissed(ids:badgeCount:)``. + private func emitNotificationsDismissed(ids: [String]) { + guard !ids.isEmpty else { return } + recordDismissTombstones(ids: ids.compactMap { UUID(uuidString: $0) }) + let unreadCount = indexes.unreadCount + // Live lane: nonisolated static fan-out; short-circuits when no phone is + // subscribed. + MobileHostService.emitEvent( + topic: Self.dismissedEventTopic, + payload: ["ids": ids, "unread_count": unreadCount] + ) + // Cold lane: mirror the dismiss through APNs for every registered + // device, attached or not (no-op unless phone forwarding is on). + PhonePushClient.shared.forwardDismissed(ids: ids, badgeCount: unreadCount) + } + + /// A user-driven dismiss emit that also carries any stale superseded-banner + /// ids the caller drained from ``supersededPhoneDismissBuffer``. Once the + /// current notification for a tab/surface is read/cleared/removed, no + /// replacement push will ever flush those stragglers (their forward was + /// throttled), so they must ride along with the triggering emit or an + /// offline phone keeps the stale banner until its next reconcile. + private func emitNotificationsDismissed(ids: [String], drainedSuperseded: [String]) { + guard !drainedSuperseded.isEmpty else { + emitNotificationsDismissed(ids: ids) + return + } + let extra = drainedSuperseded.filter { !ids.contains($0) } + emitNotificationsDismissed(ids: ids + extra) + } + + /// The last unread count pushed over ``badgeEventTopic``, so the chokepoint + /// only emits on real transitions. + private var lastEmittedPhoneBadgeCount: Int? + + /// Pushes the authoritative unread count to an attached phone whenever it + /// changes. Runs from ``refreshUnreadPresentation()`` — the same chokepoint + /// that refreshes the Mac Dock badge — so every mutation lane (markRead, + /// markUnread, record, restore, clear) keeps the phone badge correct without + /// per-call-site emits. Cheap when nothing is attached (subscriber + /// short-circuit inside `emitEvent`). + private func emitUnreadBadgeEventIfChanged() { + let count = indexes.unreadCount + guard count != lastEmittedPhoneBadgeCount else { return } + lastEmittedPhoneBadgeCount = count + MobileHostService.emitEvent( + topic: Self.badgeEventTopic, + payload: ["unread_count": count] + ) + } + private enum AuthorizationRequestOrigin: String { case notificationDelivery = "notification_delivery" case settingsButton = "settings_button" @@ -399,6 +579,7 @@ final class TerminalNotificationStore: ObservableObject { manualUnreadWorkspaceIds: manualUnreadWorkspaceIds ) refreshDockBadge() + emitUnreadBadgeEventIfChanged() } /// Builds the per-workspace unread summaries the sidebar renders. Mirrors @@ -996,6 +1177,48 @@ final class TerminalNotificationStore: ObservableObject { if !idsToClear.isEmpty { center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) + // A newer notification for this tab+surface superseded the old one + // and its Mac banner was just cleared. When a replacement banner + // push is expected, DEFER the phone-banner dismiss until that push + // is actually queued (see ``deliverNotificationSideEffects``): the + // phone must never lose its only banner to a dismissal whose + // replacement was throttled. When no replacement will be forwarded + // (suppressed/focused, non-desktop effects, forwarding off, or the + // `.onlyWhenAway` presence gate suppressing it while the Mac is + // active), emit the dismiss immediately — nothing is coming to + // replace the banner, and the Mac is not showing one either, so + // deferring would just leave the stale banner stuck until a later + // forward. Only the burst throttle is a legitimate defer-and-flush + // case, which is why ``PhonePushClient/willForwardReplacement()`` + // mirrors the real send gate but ignores that throttle. + let replacementWillForward = !shouldSuppressExternalDelivery + && effects.desktop + && PhonePushClient.shared.willForwardReplacement() + if replacementWillForward { + // The superseded entries already left the store; tombstone them + // now so the reconcile sweep stays correct while the dismiss is + // deferred. + recordDismissTombstones(ids: idsToClear.compactMap { UUID(uuidString: $0) }) + supersededPhoneDismissBuffer.stash( + ids: idsToClear, + forKey: SupersededPhoneDismissBuffer.key( + tabId: notification.tabId, + surfaceId: notification.surfaceId + ) + ) + } else { + // Also drain anything still parked for this key from an earlier + // throttled supersede; this emit is its last guaranteed ride. + emitNotificationsDismissed( + ids: idsToClear, + drainedSuperseded: supersededPhoneDismissBuffer.flush( + forKey: SupersededPhoneDismissBuffer.key( + tabId: notification.tabId, + surfaceId: notification.surfaceId + ) + ) + ) + } } deliverNotificationSideEffects( notification, @@ -1038,9 +1261,27 @@ final class TerminalNotificationStore: ObservableObject { notificationDeliveryHandler(self, notification, effects) // Mirror to the user's iPhone (opt-in, off by default). Only on the // desktop-delivery path so it matches what the Mac actually shows; - // suppressed/focused notifications are not forwarded. + // suppressed/focused notifications are not forwarded. The badge is + // the authoritative unread total at send time (the store was already + // mutated above, so it includes this notification); the server + // stamps it as `aps.badge` so the icon badge is SET, not incremented. if effects.desktop { - PhonePushClient.shared.forward(notification) + let queued = PhonePushClient.shared.forward(notification, badgeCount: indexes.unreadCount) + // Only once the replacement banner push is queued is it safe to + // clear the superseded banners it replaces (deferred from + // `recordNotification`); a throttled push leaves them stashed + // for the next successful forward of this tab/surface. + if queued { + let superseded = supersededPhoneDismissBuffer.flush( + forKey: SupersededPhoneDismissBuffer.key( + tabId: notification.tabId, + surfaceId: notification.surfaceId + ) + ) + if !superseded.isEmpty { + emitNotificationsDismissed(ids: superseded) + } + } } } } @@ -1098,9 +1339,17 @@ final class TerminalNotificationStore: ObservableObject { var updated = notifications guard let index = updated.firstIndex(where: { $0.id == id }) else { return } guard !updated[index].isRead else { return } + let supersededKey = SupersededPhoneDismissBuffer.key( + tabId: updated[index].tabId, + surfaceId: updated[index].surfaceId + ) updated[index].isRead = true notifications = updated center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) + emitNotificationsDismissed( + ids: [id.uuidString], + drainedSuperseded: supersededPhoneDismissBuffer.flush(forKey: supersededKey) + ) } func markUnread(id: UUID) { @@ -1138,17 +1387,30 @@ final class TerminalNotificationStore: ObservableObject { setWorkspaceRestoredUnread(false, forTabId: tabId) if !idsToClear.isEmpty { center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + emitNotificationsDismissed( + ids: idsToClear, + drainedSuperseded: supersededPhoneDismissBuffer.flush(matchingTabId: tabId) + ) } } func markRead(forTabId tabId: UUID, surfaceId: UUID?) { var updated = notifications var idsToClear: [String] = [] + var supersededDrained = supersededPhoneDismissBuffer.flush( + forKey: SupersededPhoneDismissBuffer.key(tabId: tabId, surfaceId: surfaceId) + ) for index in updated.indices { if updated[index].matches(tabId: tabId, surfaceId: surfaceId), !updated[index].isRead { updated[index].isRead = true idsToClear.append(updated[index].id.uuidString) + supersededDrained.append(contentsOf: supersededPhoneDismissBuffer.flush( + forKey: SupersededPhoneDismissBuffer.key( + tabId: updated[index].tabId, + surfaceId: updated[index].surfaceId + ) + )) } } if !idsToClear.isEmpty { @@ -1163,6 +1425,7 @@ final class TerminalNotificationStore: ObservableObject { if !idsToClear.isEmpty { center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) + emitNotificationsDismissed(ids: idsToClear, drainedSuperseded: supersededDrained) } } @@ -1240,6 +1503,10 @@ final class TerminalNotificationStore: ObservableObject { if !idsToClear.isEmpty { center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) + emitNotificationsDismissed( + ids: idsToClear, + drainedSuperseded: supersededPhoneDismissBuffer.flushAll() + ) } } @@ -1254,6 +1521,15 @@ final class TerminalNotificationStore: ObservableObject { clearFocusedReadIndicator(forTabId: removed.tabId, surfaceId: removed.surfaceId) } center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) + let supersededDrained = removed.map { removedNotification in + supersededPhoneDismissBuffer.flush( + forKey: SupersededPhoneDismissBuffer.key( + tabId: removedNotification.tabId, + surfaceId: removedNotification.surfaceId + ) + ) + } ?? [] + emitNotificationsDismissed(ids: [id.uuidString], drainedSuperseded: supersededDrained) } func restoreSessionNotifications(_ restoredNotifications: [TerminalNotification], forTabId tabId: UUID) { @@ -1330,6 +1606,7 @@ final class TerminalNotificationStore: ObservableObject { CmuxEventBus.shared.publishNotificationCleared(ids: ids, workspaceId: nil, surfaceId: nil) center.removeDeliveredNotificationsOffMain(withIdentifiers: ids) center.removePendingNotificationRequestsOffMain(withIdentifiers: ids) + emitNotificationsDismissed(ids: ids, drainedSuperseded: supersededPhoneDismissBuffer.flushAll()) } func clearNotifications( @@ -1343,9 +1620,18 @@ final class TerminalNotificationStore: ObservableObject { var updated: [TerminalNotification] = [] updated.reserveCapacity(notifications.count) var idsToClear: [String] = [] + var supersededDrained = supersededPhoneDismissBuffer.flush( + forKey: SupersededPhoneDismissBuffer.key(tabId: tabId, surfaceId: surfaceId) + ) for notification in notifications { if notification.matches(tabId: tabId, surfaceId: surfaceId) { idsToClear.append(notification.id.uuidString) + supersededDrained.append(contentsOf: supersededPhoneDismissBuffer.flush( + forKey: SupersededPhoneDismissBuffer.key( + tabId: notification.tabId, + surfaceId: notification.surfaceId + ) + )) } else { updated.append(notification) } @@ -1362,6 +1648,7 @@ final class TerminalNotificationStore: ObservableObject { CmuxEventBus.shared.publishNotificationCleared(ids: idsToClear, workspaceId: tabId, surfaceId: surfaceId) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) + emitNotificationsDismissed(ids: idsToClear, drainedSuperseded: supersededDrained) } } @@ -1427,6 +1714,10 @@ final class TerminalNotificationStore: ObservableObject { CmuxEventBus.shared.publishNotificationCleared(ids: idsToClear, workspaceId: tabId, surfaceId: nil) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) + emitNotificationsDismissed( + ids: idsToClear, + drainedSuperseded: supersededPhoneDismissBuffer.flush(matchingTabId: tabId) + ) } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bbf1d95c833..939ceaf0644 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -446,7 +446,7 @@ C0DE3150A00000000000001 /* MinimalModeSidebarControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE3150A00000000000002 /* MinimalModeSidebarControls.swift */; }; 284FD21ACF8BD1ED6AEBD7A0 /* MobileAttachTicketStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6D2AC715764ECC1F6B26A2 /* MobileAttachTicketStore.swift */; }; 6AA2F2E8BEB19ED6B8E125DB /* MobileHostAuthorizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD519CFE74530A46DF0925D /* MobileHostAuthorizationTests.swift */; }; - C0DE1D010000000000000001 /* MobileHostBuildIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1D010000000000000002 /* MobileHostBuildIdentity.swift */; }; + C7A50B000000000000000016 /* MobileHostBuildIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50B000000000000000015 /* MobileHostBuildIdentity.swift */; }; 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */; }; C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */; }; F67FBC88671C40AC3A3284CE /* MobileHostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ACA497A6831165EA879C642 /* MobileHostService.swift */; }; @@ -466,6 +466,7 @@ B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; 8B1B2AB6EC6ACDB2CD39A9BB /* NewBrowserWorkspaceShortcutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04698069140539D8495D6B9B /* NewBrowserWorkspaceShortcutUITests.swift */; }; 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */; }; + D7AB00000000000000B021 /* NotificationDismissSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */; }; B7F00002 /* NotificationSoundSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F00001 /* NotificationSoundSettings.swift */; }; 4E5F60720000000000000001 /* NotificationSoundSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */; }; A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; @@ -630,6 +631,7 @@ F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */; }; F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; }; D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B71010000000000000002 /* StartupBreadcrumbLog.swift */; }; + D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */; }; C3677003000000000000001 /* TabManager+CompatibilityTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */; }; @@ -666,6 +668,7 @@ C0DE00000000000000000C7C /* TerminalController+ControlSystemContext2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C7B /* TerminalController+ControlSystemContext2.swift */; }; C0DE00000000000000000C62 /* TerminalController+ControlWorkspaceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C61 /* TerminalController+ControlWorkspaceContext.swift */; }; C0DE00000000000000000C54 /* TerminalController+ControlWorkspaceGroupContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */; }; + D7AB00000000000000B001 /* TerminalController+MobileNotificationSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */; }; C7A50B000000000000000012 /* TerminalController+MobileWorkspaceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50B000000000000000011 /* TerminalController+MobileWorkspaceList.swift */; }; D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; @@ -1259,7 +1262,7 @@ C0DE3150A00000000000002 /* MinimalModeSidebarControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/MinimalModeSidebarControls.swift; sourceTree = ""; }; 8D6D2AC715764ECC1F6B26A2 /* MobileAttachTicketStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileAttachTicketStore.swift; sourceTree = ""; }; 9BD519CFE74530A46DF0925D /* MobileHostAuthorizationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostAuthorizationTests.swift; sourceTree = ""; }; - C0DE1D010000000000000002 /* MobileHostBuildIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostBuildIdentity.swift; sourceTree = ""; }; + C7A50B000000000000000015 /* MobileHostBuildIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileHostBuildIdentity.swift; sourceTree = ""; }; 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostRPC.swift; sourceTree = ""; }; C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+Capabilities.swift"; sourceTree = ""; }; 7ACA497A6831165EA879C642 /* MobileHostService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostService.swift; sourceTree = ""; }; @@ -1279,6 +1282,7 @@ B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; 04698069140539D8495D6B9B /* NewBrowserWorkspaceShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewBrowserWorkspaceShortcutUITests.swift; sourceTree = ""; }; D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; }; + D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationDismissSyncTests.swift"; sourceTree = ""; }; B7F00001 /* NotificationSoundSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundSettings.swift; sourceTree = ""; }; 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundSettingsTests.swift; sourceTree = ""; }; A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = ""; }; @@ -1433,6 +1437,7 @@ E3309A0E /* SplitEqualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitEqualizer.swift; sourceTree = ""; }; F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHStartupSignalLifecycleTests.swift; sourceTree = ""; }; D35B71010000000000000002 /* StartupBreadcrumbLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/StartupBreadcrumbLog.swift; sourceTree = ""; }; + D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceGroups.swift"; sourceTree = ""; }; C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+CompatibilityTypes.swift"; sourceTree = ""; }; @@ -1469,6 +1474,7 @@ C0DE00000000000000000C7B /* TerminalController+ControlSystemContext2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSystemContext2.swift"; sourceTree = ""; }; C0DE00000000000000000C61 /* TerminalController+ControlWorkspaceContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlWorkspaceContext.swift"; sourceTree = ""; }; C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlWorkspaceGroupContext.swift"; sourceTree = ""; }; + D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileNotificationSync.swift"; sourceTree = ""; }; C7A50B000000000000000011 /* TerminalController+MobileWorkspaceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileWorkspaceList.swift"; sourceTree = ""; }; D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; @@ -1763,8 +1769,8 @@ children = ( 8D6D2AC715764ECC1F6B26A2 /* MobileAttachTicketStore.swift */, 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */, - C0DE1D010000000000000002 /* MobileHostBuildIdentity.swift */, 7ACA497A6831165EA879C642 /* MobileHostService.swift */, + C7A50B000000000000000015 /* MobileHostBuildIdentity.swift */, 4C1A7E10B2D34F56A8C90012 /* MobileHostStatusVerificationLimiter.swift */, C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */, 7A956C76162B79EC74AF9965 /* MobileRouteResolver.swift */, @@ -2010,6 +2016,7 @@ C0DE00000000000000000C43 /* TerminalController+ControlFeedContext.swift */, C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */, C0DE00000000000000000C61 /* TerminalController+ControlWorkspaceContext.swift */, + D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */, C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */, C0DE00000000000000000C6D /* TerminalController+ControlDebugContext.swift */, C0DE00000000000000000C6F /* TerminalController+ControlProjectContext.swift */, @@ -2070,6 +2077,7 @@ D1F0A00600000000000000F2 /* ScreenLockObserver.swift */, B7F00001 /* NotificationSoundSettings.swift */, A5001092 /* TerminalNotificationStore.swift */, + D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */, A5001097 /* TerminalNotificationPolicy.swift */, F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */, A5A5A502A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift */, @@ -2429,6 +2437,7 @@ 86544CEFA1CA33CA9225FB6E /* MobileWorkspaceListFidelityTests.swift */, B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, + D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */, 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, D7C0DE00000000000000A104 /* CmuxWebViewContextMenuLinkCaptureTests.swift */, @@ -3130,7 +3139,7 @@ 3865A0033865A0033865A003 /* MenubarSearchPopover.swift in Sources */, C0DE3150A00000000000001 /* MinimalModeSidebarControls.swift in Sources */, 284FD21ACF8BD1ED6AEBD7A0 /* MobileAttachTicketStore.swift in Sources */, - C0DE1D010000000000000001 /* MobileHostBuildIdentity.swift in Sources */, + C7A50B000000000000000016 /* MobileHostBuildIdentity.swift in Sources */, 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */, C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */, F67FBC88671C40AC3A3284CE /* MobileHostService.swift in Sources */, @@ -3234,6 +3243,7 @@ A5001226 /* SocketControlMode+Display.swift in Sources */, E3309A0D /* SplitEqualizer.swift in Sources */, D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, + D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */, C3677003000000000000001 /* TabManager+CompatibilityTypes.swift in Sources */, @@ -3264,6 +3274,7 @@ C0DE00000000000000000C7C /* TerminalController+ControlSystemContext2.swift in Sources */, C0DE00000000000000000C62 /* TerminalController+ControlWorkspaceContext.swift in Sources */, C0DE00000000000000000C54 /* TerminalController+ControlWorkspaceGroupContext.swift in Sources */, + D7AB00000000000000B001 /* TerminalController+MobileNotificationSync.swift in Sources */, C7A50B000000000000000012 /* TerminalController+MobileWorkspaceList.swift in Sources */, D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, @@ -3555,6 +3566,7 @@ 9B08F916D7FF7626C6820727 /* MobilePairingConnectionTransitionTests.swift in Sources */, F6448F377504F33956E7E03B /* MobileWorkspaceListFidelityTests.swift in Sources */, 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */, + D7AB00000000000000B021 /* NotificationDismissSyncTests.swift in Sources */, 4E5F60720000000000000001 /* NotificationSoundSettingsTests.swift in Sources */, 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */, 0A0F00550000000000000001 /* OmpSupportTests.swift in Sources */, diff --git a/cmuxTests/NotificationDismissSyncTests.swift b/cmuxTests/NotificationDismissSyncTests.swift new file mode 100644 index 00000000000..ca0491e5573 --- /dev/null +++ b/cmuxTests/NotificationDismissSyncTests.swift @@ -0,0 +1,394 @@ +import Testing +import AppKit +import UserNotifications +import CMUXMobileCore + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Cross-device notification dismiss-sync: the mobile `notification.dismiss` +/// and `notification.reconcile` host verbs, dismiss tombstones, the +/// superseded-banner dismiss buffer, and the authoritative phone badge count. +/// +/// Serialized because every case mutates process-wide singletons +/// (`TerminalNotificationStore.shared`, `UserDefaults.standard`); each restores +/// the prior state in a `defer`, but they must not interleave. +@MainActor +@Suite(.serialized) +struct NotificationDismissSyncTests { + + // MARK: - notification.dismiss (cross-device dismiss-sync) + + /// A phone-side banner swipe routes `notification.dismiss` over the mobile + /// host channel and must mark the matching Mac notification *read* (banner + /// cleared, entry retained), mirroring a Mac-side banner swipe — NOT remove + /// it like the socket `notification.dismiss` verb. + @Test func mobileNotificationDismissMarksReadAndKeepsEntry() async throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + defer { store.replaceNotificationsForTesting(previousNotifications) } + + let tabId = UUID() + let target = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: UUID(), + title: "Dismiss me", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_000), isRead: false + ) + let sibling = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: UUID(), + title: "Keep me", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_001), isRead: false + ) + store.replaceNotificationsForTesting([target, sibling]) + + let response = await TerminalController.shared.mobileHostHandleRPC( + MobileHostRPCRequest( + id: "dismiss", + method: "notification.dismiss", + params: ["notification_id": target.id.uuidString], + auth: nil + ) + ) + + guard case let .ok(rawPayload) = response else { + Issue.record("Expected notification.dismiss to succeed, got \(response)") + return + } + let payload = try #require(rawPayload as? [String: Any]) + #expect(payload["dismissed"] as? Int == 1) + // markRead, not remove: both entries remain in the store. + #expect(store.notifications.first(where: { $0.id == target.id })?.isRead == true) + #expect(store.notifications.first(where: { $0.id == sibling.id })?.isRead == false) + #expect(store.notifications.count == 2) + } + + /// A batched Mac clear (e.g. "Mark all read" on the phone) sends an id array; + /// every listed notification is marked read, unknown ids are ignored. + @Test func mobileNotificationDismissAcceptsIdArrayAndIgnoresUnknownIds() async throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + defer { store.replaceNotificationsForTesting(previousNotifications) } + + let tabId = UUID() + let first = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: UUID(), + title: "One", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_000), isRead: false + ) + let second = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: UUID(), + title: "Two", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_001), isRead: false + ) + store.replaceNotificationsForTesting([first, second]) + + let response = await TerminalController.shared.mobileHostHandleRPC( + MobileHostRPCRequest( + id: "dismiss-batch", + method: "notification.dismiss", + params: [ + "notification_ids": [ + first.id.uuidString, + UUID().uuidString, // unknown id, ignored by markRead + second.id.uuidString, + ] + ], + auth: nil + ) + ) + + guard case let .ok(rawPayload) = response else { + Issue.record("Expected batched notification.dismiss to succeed, got \(response)") + return + } + let payload = try #require(rawPayload as? [String: Any]) + // dismissed counts real unread→read transitions: the two known unread + // notifications, not the ignored unknown id. + #expect(payload["dismissed"] as? Int == 2) + #expect(store.notifications.first(where: { $0.id == first.id })?.isRead == true) + #expect(store.notifications.first(where: { $0.id == second.id })?.isRead == true) + } + + /// A duplicated id in one request (retry artifacts, outbox replay) must + /// count one dismissal, not double-count or run the markRead path twice. + @Test func mobileNotificationDismissDedupesDuplicateIds() async throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + defer { store.replaceNotificationsForTesting(previousNotifications) } + + let target = TerminalNotification( + id: UUID(), tabId: UUID(), surfaceId: UUID(), + title: "Dismiss once", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_000), isRead: false + ) + store.replaceNotificationsForTesting([target]) + + let response = await TerminalController.shared.mobileHostHandleRPC( + MobileHostRPCRequest( + id: "dismiss-dup", + method: "notification.dismiss", + params: [ + "notification_ids": [ + target.id.uuidString, + target.id.uuidString, + target.id.uuidString, + ] + ], + auth: nil + ) + ) + + guard case let .ok(rawPayload) = response else { + Issue.record("Expected duplicated notification.dismiss to succeed, got \(response)") + return + } + let payload = try #require(rawPayload as? [String: Any]) + #expect(payload["dismissed"] as? Int == 1) + #expect(store.notifications.first(where: { $0.id == target.id })?.isRead == true) + } + + /// A request with no usable id is a client bug, not a silent no-op. + @Test func mobileNotificationDismissRejectsMissingId() async throws { + let response = await TerminalController.shared.mobileHostHandleRPC( + MobileHostRPCRequest( + id: "dismiss-bad", + method: "notification.dismiss", + params: ["notification_id": "not-a-uuid"], + auth: nil + ) + ) + + guard case let .failure(error) = response else { + Issue.record("Expected malformed notification.dismiss to fail, got \(response)") + return + } + #expect(error.code == "invalid_params") + } + + // MARK: - notification.reconcile (foreground sweep) + unread badge count + + /// The phone's reconcile sweep sends its delivered banner ids; the Mac must + /// report handled = read-in-store OR recently-removed (tombstoned), leave + /// unread and foreign ids alone, and return the authoritative unread count + /// the phone SETS its icon badge to. + @Test func mobileNotificationReconcileClassifiesHandledAndReportsUnreadCount() async throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + defer { store.replaceNotificationsForTesting(previousNotifications) } + + let tabId = UUID() + let read = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: UUID(), + title: "Read on Mac", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_000), isRead: true + ) + let unread = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: UUID(), + title: "Still unread", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_001), isRead: false + ) + let removed = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: UUID(), + title: "Removed on Mac", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_002), isRead: false + ) + store.replaceNotificationsForTesting([read, unread, removed]) + // User-driven removal: the entry leaves the store but must stay + // reconcilable through the dismiss tombstone. + store.remove(id: removed.id) + + // A banner mirrored from a different paired Mac; this Mac has never seen + // its id and must NOT claim it as handled. + let foreignId = UUID() + let response = await TerminalController.shared.mobileHostHandleRPC( + MobileHostRPCRequest( + id: "reconcile", + method: "notification.reconcile", + params: [ + "delivered_ids": [ + read.id.uuidString, + unread.id.uuidString, + removed.id.uuidString, + foreignId.uuidString, + ] + ], + auth: nil + ) + ) + + guard case let .ok(rawPayload) = response else { + Issue.record("Expected notification.reconcile to succeed, got \(response)") + return + } + let payload = try #require(rawPayload as? [String: Any]) + #expect(payload["handled_ids"] as? [String] == [read.id.uuidString, removed.id.uuidString]) + // The phone badge mirrors unread notification *entries*: only `unread` + // remains unread in the store. + #expect(payload["unread_count"] as? Int == 1) + } + + /// An empty `delivered_ids` is a valid badge-only sync: nothing handled, + /// count still returned. + @Test func mobileNotificationReconcileEmptyDeliveredIsBadgeOnlySync() async throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + defer { store.replaceNotificationsForTesting(previousNotifications) } + + let unread = TerminalNotification( + id: UUID(), tabId: UUID(), surfaceId: UUID(), + title: "Unread", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_000), isRead: false + ) + store.replaceNotificationsForTesting([unread]) + + let response = await TerminalController.shared.mobileHostHandleRPC( + MobileHostRPCRequest( + id: "reconcile-empty", + method: "notification.reconcile", + params: ["delivered_ids": [String]()], + auth: nil + ) + ) + + guard case let .ok(rawPayload) = response else { + Issue.record("Expected badge-only notification.reconcile to succeed, got \(response)") + return + } + let payload = try #require(rawPayload as? [String: Any]) + #expect(payload["handled_ids"] as? [String] == []) + #expect(payload["unread_count"] as? Int == 1) + } + + /// markRead leaves a dismiss tombstone, but a later markUnread resurrects + /// the entry: a currently-unread id must never be reported handled, or the + /// reconcile sweep would clear a banner the user explicitly un-read. + @Test func reconcileUnreadEntryBeatsStaleDismissTombstone() throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + defer { store.replaceNotificationsForTesting(previousNotifications) } + + let notification = TerminalNotification( + id: UUID(), tabId: UUID(), surfaceId: UUID(), + title: "Resurrected", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_000), isRead: false + ) + store.replaceNotificationsForTesting([notification]) + store.markRead(id: notification.id) // records a dismiss tombstone + #expect( + store.reconcileHandledNotificationIDs(deliveredIDs: [notification.id]) + == [notification.id.uuidString] + ) + + store.markUnread(id: notification.id) + + #expect(store.reconcileHandledNotificationIDs(deliveredIDs: [notification.id]) == []) + } + + /// Dismiss tombstones are write-through persisted: a notification dismissed + /// and fully removed before a Mac relaunch must still reconcile as handled + /// afterwards, or a phone whose silent dismiss push was dropped would keep + /// the stale banner forever. + @Test func dismissTombstonesSurviveStoreReload() throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tombstoneKey = TerminalNotificationStore.dismissedTombstoneDefaultsKey + let previousTombstones = UserDefaults.standard.stringArray(forKey: tombstoneKey) + defer { + store.replaceNotificationsForTesting(previousNotifications) + if let previousTombstones { + UserDefaults.standard.set(previousTombstones, forKey: tombstoneKey) + } else { + UserDefaults.standard.removeObject(forKey: tombstoneKey) + } + store.reloadDismissedTombstonesForTesting() + } + + let notification = TerminalNotification( + id: UUID(), tabId: UUID(), surfaceId: UUID(), + title: "Cleared before relaunch", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_000), isRead: false + ) + store.replaceNotificationsForTesting([notification]) + store.markRead(id: notification.id) // records a persisted tombstone + store.replaceNotificationsForTesting([]) // the entry leaves the store entirely + + // The behavior-test analogue of a Mac relaunch: drop the in-memory ring + // so the next reconcile must re-read the persisted copy. + store.reloadDismissedTombstonesForTesting() + + #expect( + store.reconcileHandledNotificationIDs(deliveredIDs: [notification.id]) + == [notification.id.uuidString] + ) + } + + /// Superseded phone-banner dismissals are deferred behind the (throttled) + /// replacement push: the buffer must accumulate ids across throttled + /// supersedes, dedupe replays, and hand everything over exactly once when + /// the replacement push finally goes out. + @Test func supersededPhoneDismissBufferAccumulatesAndFlushesOnce() { + var buffer = SupersededPhoneDismissBuffer() + let key = SupersededPhoneDismissBuffer.key(tabId: UUID(), surfaceId: UUID()) + + buffer.stash(ids: ["a"], forKey: key) + buffer.stash(ids: ["b", "a"], forKey: key) // replayed "a" kept once + + #expect(buffer.flush(forKey: key) == ["a", "b"]) + #expect(buffer.flush(forKey: key) == []) + } + + @Test func supersededPhoneDismissBufferIsBoundedAndPerKey() { + var buffer = SupersededPhoneDismissBuffer() + let hot = SupersededPhoneDismissBuffer.key(tabId: UUID(), surfaceId: UUID()) + let other = SupersededPhoneDismissBuffer.key(tabId: UUID(), surfaceId: nil) + + buffer.stash(ids: (0..<70).map { "n-\($0)" }, forKey: hot) + buffer.stash(ids: ["x"], forKey: other) + + let flushed = buffer.flush(forKey: hot) + #expect(flushed.count == SupersededPhoneDismissBuffer.capacityPerKey) + #expect(flushed.first == "n-6") // oldest evicted past the cap + #expect(flushed.last == "n-69") + #expect(buffer.flush(forKey: other) == ["x"]) // keys independent + } + + /// Tab-scoped read/clear operations must drain every surface key under the + /// tab (after them no surface in the tab has an unread entry), while other + /// tabs' stashes stay put; clear-all/mark-all-read drain everything. + @Test func supersededPhoneDismissBufferTabAndGlobalFlush() { + var buffer = SupersededPhoneDismissBuffer() + let tabA = UUID() + let tabB = UUID() + buffer.stash(ids: ["a1"], forKey: SupersededPhoneDismissBuffer.key(tabId: tabA, surfaceId: UUID())) + buffer.stash(ids: ["a2"], forKey: SupersededPhoneDismissBuffer.key(tabId: tabA, surfaceId: nil)) + buffer.stash(ids: ["b1"], forKey: SupersededPhoneDismissBuffer.key(tabId: tabB, surfaceId: UUID())) + + #expect(buffer.flush(matchingTabId: tabA).sorted() == ["a1", "a2"]) + #expect(buffer.flush(matchingTabId: tabA) == []) // drained exactly once + + #expect(buffer.flushAll() == ["b1"]) + #expect(buffer.flushAll() == []) + } + + /// The phone badge counts unread notification entries only. Workspace-level + /// manual unread indicators feed the Mac Dock badge but have no phone banner, + /// so they must not inflate the phone count. + @Test func phoneBadgeCountsNotificationEntriesNotWorkspaceIndicators() throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + defer { store.replaceNotificationsForTesting(previousNotifications) } + + let tabId = UUID() + store.replaceNotificationsForTesting([]) // also clears manual unread state + let dockCountBefore = store.unreadCount + + store.markUnread(forTabId: tabId) // workspace indicator, no entry + defer { store.markRead(forTabId: tabId) } + + #expect(store.unreadCount == dockCountBefore + 1) + #expect(store.unreadNotificationCount == 0) + } +} From 0452e49e299144e3282d1dc0d4a3fd225883fed8 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:03:33 -0700 Subject: [PATCH 20/31] Restore iOS notification dismiss push hooks --- .../MobilePushCoordinator.swift | 85 ++++- ios/cmux/CmuxAppDelegate.swift | 66 +++- web/services/apns/payload.ts | 60 +++- web/services/apns/routePolicy.ts | 71 +++- web/services/apns/sender.ts | 42 ++- web/tests/apns.test.ts | 335 ++++++++++++++++++ 6 files changed, 646 insertions(+), 13 deletions(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index ce9dcdf3b6d..96529553edd 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -23,11 +23,28 @@ import UserNotifications public final class MobilePushCoordinator { private let registration: any PushRegistering private let analytics: any AnalyticsEmitting + /// The system-notification surface used by the cold dismiss lane. Owned here + /// (not via the store) because a silent dismiss push can wake the app in the + /// background before any scene — and therefore any store — exists. + private let deliveredNotificationClearer: any DeliveredNotificationClearing + /// Durable phone→Mac dismiss outbox for swipes that arrive before any shell + /// store exists (a background launch from Notification Center). Backed by + /// the same `UserDefaults` key the store's own queue uses, so the store's + /// flush-on-subscribe delivers these too. + @ObservationIgnored private let pendingDismissQueue: PendingNotificationDismissQueue // UserDefaults is Apple-documented thread-safe; a synchronous read mirrors // the opt-in flag for the menu UI without awaiting the actor service. private nonisolated(unsafe) let defaults: UserDefaults private static let enabledKey = "cmux.notifications.pushEnabled" + /// APNs `aps.category` the web sets on every cmux terminal push (see + /// `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The matching + /// ``UNNotificationCategory`` registered below carries + /// `.customDismissAction`, so a swipe/clear delivers + /// `UNNotificationDismissActionIdentifier` to the app and we can forward the + /// dismiss to the Mac. Keep these two ids in sync. + public static let dismissSyncCategoryIdentifier = "cmux.terminal" + @ObservationIgnored private weak var store: CMUXMobileShellStore? /// A tap whose navigation could not complete yet. On a cold launch the @@ -56,17 +73,27 @@ public final class MobilePushCoordinator { /// ``NoopAnalytics`` for previews/tests. /// - defaults: The store backing the opt-in flag (must match the suite the /// registration service uses). Defaults to `.standard`. + /// - deliveredNotificationClearer: The system-notification seam used to + /// remove banners for a background dismiss push. Defaults to the real + /// `UNUserNotificationCenter`-backed conformance. + /// - pendingDismissQueue: The durable phone→Mac dismiss outbox shared (via + /// `UserDefaults`) with the shell store, used when a swipe arrives before + /// any store exists. Defaults to the standard-defaults-backed queue. /// - now: Clock seam for the pending-deeplink expiry. Defaults to /// `Date.init`. public init( registration: any PushRegistering, analytics: any AnalyticsEmitting = NoopAnalytics(), defaults: UserDefaults = .standard, + deliveredNotificationClearer: any DeliveredNotificationClearing = SystemDeliveredNotificationClearer(), + pendingDismissQueue: PendingNotificationDismissQueue = PendingNotificationDismissQueue(), now: @escaping () -> Date = Date.init ) { self.registration = registration self.analytics = analytics self.defaults = defaults + self.deliveredNotificationClearer = deliveredNotificationClearer + self.pendingDismissQueue = pendingDismissQueue self.now = now } @@ -86,11 +113,23 @@ public final class MobilePushCoordinator { applyPendingDeeplinkIfReady() } - /// Install the notification-center delegate and, if already opted in, - /// re-assert remote registration so a rotated token re-uploads. Call once at - /// launch from the AppDelegate. + /// Install the notification-center delegate, register the dismiss-sync + /// notification category, and, if already opted in, re-assert remote + /// registration so a rotated token re-uploads. Call once at launch from the + /// AppDelegate. public func configure(delegate: any UNUserNotificationCenterDelegate) { - UNUserNotificationCenter.current().delegate = delegate + let center = UNUserNotificationCenter.current() + center.delegate = delegate + // The category must carry `.customDismissAction` so a swipe/clear of a + // cmux banner delivers `UNNotificationDismissActionIdentifier` to the + // delegate; that is what lets us tell the Mac the user dismissed it. + let dismissSyncCategory = UNNotificationCategory( + identifier: Self.dismissSyncCategoryIdentifier, + actions: [], + intentIdentifiers: [], + options: [.customDismissAction] + ) + center.setNotificationCategories([dismissSyncCategory]) if isEnabled { UIApplication.shared.registerForRemoteNotifications() } @@ -231,5 +270,43 @@ public final class MobilePushCoordinator { "resolved_surface": .bool(pending.surfaceId != nil), ]) } + + /// Forward a phone-side notification dismissal to the paired Mac so it marks + /// the notification read and clears its own banner. Fire-and-forget over the + /// attach channel; carries only the opaque notification id, never content. + /// + /// Durable: a swipe can background-launch the app from Notification Center + /// before any scene — and therefore any store — exists. In that case the id + /// is parked in ``PendingNotificationDismissQueue`` and the store flushes it + /// on its next successful (re)subscribe. With a store, the store's own + /// enqueue-first send provides the same guarantee for a down channel. + /// - Parameter notificationId: The stable id of the dismissed notification. + /// For a remote push this is `request.identifier` (the `apns-collapse-id`), + /// with `cmux.notificationId` as a fallback. + public func handleDismiss(notificationId: String?) async { + guard let notificationId else { return } + let trimmed = notificationId.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + guard let store else { + pendingDismissQueue.enqueue([trimmed]) + return + } + await store.dismissNotification(ids: [trimmed]) + } + + /// Handle a silent Mac→iOS dismiss push (the cold lane, fanned out to every + /// registered device after a Mac-side clear). Removes the matching + /// delivered banners directly through the system-notification seam — the + /// store may not exist yet on a background wake — while the badge was + /// already applied by the system from the push's `aps.badge`. + /// - Parameter ids: The dismissed stable notification ids from + /// `cmux.dismissedIds`. + public func handleRemoteDismiss(ids: [String]) async { + let trimmed = ids + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return } + await deliveredNotificationClearer.removeDelivered(ids: trimmed) + } } #endif diff --git a/ios/cmux/CmuxAppDelegate.swift b/ios/cmux/CmuxAppDelegate.swift index 716d7e33e47..48a7aa96370 100644 --- a/ios/cmux/CmuxAppDelegate.swift +++ b/ios/cmux/CmuxAppDelegate.swift @@ -66,7 +66,21 @@ final class CmuxAppDelegate: NSObject, UIApplicationDelegate, UNUserNotification _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse ) async { - let ids = Self.cmuxIDs(from: response.notification.request.content.userInfo) + let request = response.notification.request + // A swipe/clear of a cmux banner delivers the custom dismiss action + // (enabled via the `cmux.terminal` category's `.customDismissAction`). + // Forward it to the Mac so the desktop banner + store entry clear too. + if response.actionIdentifier == UNNotificationDismissActionIdentifier { + await pushCoordinator?.handleDismiss( + notificationId: Self.notificationID(from: request) + ) + return + } + // A tap (default action) deep-links to the workspace/terminal AND marks + // the notification read on the Mac, mirroring the Mac's own tap path + // (which opens + marks read). The two compose: deep-link locally, clear + // on the Mac. + let ids = Self.cmuxIDs(from: request.content.userInfo) let appState = await UIApplication.shared.applicationState await analytics?.capture("ios_push_tapped", [ "has_workspace_id": .bool(ids.workspaceId != nil), @@ -77,6 +91,37 @@ final class CmuxAppDelegate: NSObject, UIApplicationDelegate, UNUserNotification workspaceId: ids.workspaceId, surfaceId: ids.surfaceId ) + await pushCoordinator?.handleDismiss( + notificationId: Self.notificationID(from: request) + ) + } + + /// Silent dismiss push (the cold lane of Mac→iOS dismiss-sync): the Mac + /// cleared notifications and sent every registered device a + /// `content-available` push carrying the dismissed ids (idempotent no-op if + /// this device already handled the live peer event). The system applies + /// the authoritative badge from `aps.badge` without waking us; when iOS + /// grants the background wake — strictly budgeted, a handful per hour at + /// best — we also remove the matching delivered banners. Anything iOS + /// defers is healed by the reconcile sweep on the next app open/attach. + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any] + ) async -> UIBackgroundFetchResult { + let dismissedIds = Self.dismissedIDs(from: userInfo) + guard !dismissedIds.isEmpty else { return .noData } + await pushCoordinator?.handleRemoteDismiss(ids: dismissedIds) + return .newData + } + + private nonisolated static func dismissedIDs(from userInfo: [AnyHashable: Any]) -> [String] { + guard let cmux = userInfo["cmux"] as? [String: Any], + let ids = cmux["dismissedIds"] as? [String] else { + return [] + } + return ids + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } } @MainActor @@ -95,4 +140,23 @@ final class CmuxAppDelegate: NSObject, UIApplicationDelegate, UNUserNotification guard let cmux = userInfo["cmux"] as? [String: Any] else { return (nil, nil) } return (cmux["workspaceId"] as? String, cmux["surfaceId"] as? String) } + + /// The stable Mac-side notification id for a delivered request, or `nil` when + /// this push does not carry one. + /// + /// The `cmux.notificationId` payload key is authoritative: the Mac stamps the + /// same value as `apns-collapse-id`, so it equals `request.identifier` for a + /// modern push. We deliberately do NOT fall back to a bare `request.identifier` + /// when the payload key is absent: a push without `notificationId` (an older + /// Mac, or any push that omitted it) has an OS-assigned random identifier that + /// matches no Mac notification, so forwarding it would mark the wrong (or no) + /// notification read. Returning `nil` degrades cleanly to "no dismiss-sync". + private nonisolated static func notificationID(from request: UNNotificationRequest) -> String? { + guard let cmux = request.content.userInfo["cmux"] as? [String: Any], + let id = (cmux["notificationId"] as? String)?.trimmingCharacters(in: .whitespaces), + !id.isEmpty else { + return nil + } + return id + } } diff --git a/web/services/apns/payload.ts b/web/services/apns/payload.ts index 8a3f1bca6cf..b876e6d2b6c 100644 --- a/web/services/apns/payload.ts +++ b/web/services/apns/payload.ts @@ -14,22 +14,56 @@ export function apnsHostForEnvironment(environment: string): string { } export interface ApnsNotificationInput { + /** + * `notify` (default) is the visible terminal-banner mirror; `dismiss` is the + * banner-less Mac→iOS dismiss-sync push (`content-available` + badge + + * `cmux.dismissedIds`) fanned out to every registered device. + */ + readonly kind?: "notify" | "dismiss"; readonly title: string; readonly subtitle?: string | null; readonly body: string; readonly workspaceId?: string | null; readonly surfaceId?: string | null; + /** + * Stable Mac-side notification id. Surfaced in the payload as + * `cmux.notificationId` so an iOS swipe-dismiss can tell the Mac which + * notification was cleared. The sender also stamps it as `apns-collapse-id` + * so a later Mac→iOS dismiss can target this exact delivered banner. + */ + readonly notificationId?: string | null; + /** The dismissed notification ids carried by a `dismiss` push. */ + readonly dismissedIds?: readonly string[]; + /** + * Authoritative unread count computed by the Mac at send time; emitted as + * `aps.badge` so the icon badge is always SET to the absolute total (never + * incremented locally) and drift self-heals. `null`/absent leaves the badge + * untouched. + */ + readonly badgeCount?: number | null; /** When true, replace real terminal text with a generic fallback. Keep the * fallback literal until device tokens carry client localization capability. */ readonly hideContent?: boolean; } /** - * Build the APNs JSON payload. Adds `cmux.workspaceId`/`cmux.surfaceId` custom - * keys so a tapped notification can deep-link to the right terminal, and marks - * the alert time-sensitive (the app holds that entitlement). + * APNs `aps.category` set on every cmux terminal push. iOS registers a + * matching ``UNNotificationCategory`` with `customDismissAction` so a + * swipe/clear delivers `UNNotificationDismissActionIdentifier` to the app, + * which forwards the dismiss to the Mac. Keep this in sync with the iOS + * category id. + */ +export const CMUX_APNS_CATEGORY = "cmux.terminal"; + +/** + * Build the APNs JSON payload. Adds `cmux.workspaceId`/`cmux.surfaceId`/ + * `cmux.notificationId` custom keys so a tapped notification can deep-link to + * the right terminal and a swipe can be dismiss-synced, sets the dismiss-action + * `category`, and marks the alert time-sensitive (the app holds that + * entitlement). */ export function buildApnsPayload(input: ApnsNotificationInput): Record { + if (input.kind === "dismiss") return buildDismissPayload(input); const hidden = input.hideContent === true; const title = hidden ? "cmux" : input.title.trim() || "cmux"; const body = hidden ? "An agent needs your attention" : input.body; @@ -43,15 +77,35 @@ export function buildApnsPayload(input: ApnsNotificationInput): Record = {}; if (input.workspaceId) cmux.workspaceId = input.workspaceId; if (input.surfaceId) cmux.surfaceId = input.surfaceId; + if (input.notificationId) cmux.notificationId = input.notificationId; return Object.keys(cmux).length > 0 ? { aps, cmux } : { aps }; } +/** + * The Mac→iOS dismiss-sync push: no alert/sound/category (nothing visible), + * `aps.badge` set to the authoritative unread total (applied by the system even + * when iOS declines to wake the app), and `content-available: 1` so iOS wakes + * the app — within its strictly budgeted background-push allowance — to remove + * the dismissed delivered banners listed under `cmux.dismissedIds`. + * + * Deliberately sent as push-type `alert` with priority 5 (see sender): per + * Apple's push-type taxonomy a badge update is user-facing, so this is not a + * `background` push, and a `background` push may not carry `badge` at all. + */ +function buildDismissPayload(input: ApnsNotificationInput): Record { + const aps: Record = { "content-available": 1 }; + if (typeof input.badgeCount === "number") aps.badge = input.badgeCount; + return { aps, cmux: { dismissedIds: [...(input.dismissedIds ?? [])] } }; +} + /** * Whether an APNs response means the token is permanently invalid and should be * deleted. 410 (Unregistered, with a timestamp) and the `BadDeviceToken` / diff --git a/web/services/apns/routePolicy.ts b/web/services/apns/routePolicy.ts index 8adf9eacd5a..89c536d0dfd 100644 --- a/web/services/apns/routePolicy.ts +++ b/web/services/apns/routePolicy.ts @@ -5,18 +5,47 @@ export const MAX_PUSH_SUBTITLE_CHARS = 120; export const MAX_PUSH_BODY_CHARS = 500; export const MAX_PUSH_ID_CHARS = 200; export const MAX_PUSH_REQUEST_BYTES = 8 * 1024; +/** Max dismissed-notification ids one dismiss push may carry; the Mac chunks. */ +export const MAX_PUSH_DISMISS_IDS = 64; +/** Badge ceiling; iOS renders large numbers fine but a runaway count is a bug. */ +export const MAX_PUSH_BADGE_COUNT = 9999; export type ApnsBundlePolicy = { readonly bundleId: string; readonly environment: "sandbox" | "production"; }; +/** + * What a push request asks APNs to do. `notify` is the visible terminal-banner + * mirror (the default; older Macs never send `kind`). `dismiss` is the cold + * lane of Mac→iOS dismiss-sync: a banner-less `content-available` push carrying + * the dismissed ids plus the authoritative badge, fanned out to every + * registered device (idempotent on devices that got the live event). + */ +export type PushKind = "notify" | "dismiss"; + export type PushPayload = { + readonly kind: PushKind; readonly title: string; readonly subtitle: string | null; readonly body: string; readonly workspaceId: string | null; readonly surfaceId: string | null; + /** + * Stable Mac-side notification id. Sent to APNs as `apns-collapse-id` and as + * `cmux.notificationId` so cross-device dismiss-sync can target the exact + * delivered banner. An opaque id, never terminal content. + */ + readonly notificationId: string | null; + /** Dismissed notification ids carried by a `dismiss` push (else empty). */ + readonly dismissedIds: readonly string[]; + /** + * Authoritative unread count computed by the Mac at send time, applied to the + * iOS app icon as `aps.badge`. The phone never does local badge arithmetic; + * every push sets the absolute total so drift self-heals. `null` = leave the + * badge alone (older Macs). + */ + readonly badgeCount: number | null; readonly hideContent: boolean; }; @@ -53,32 +82,72 @@ export function normalizeApnsBundle(bundleId: string): ApnsBundlePolicy | null { } export function parsePushPayload(body: Record): PushPayloadResult { + const kind: PushKind = body.kind === "dismiss" ? "dismiss" : "notify"; const title = boundedString(body.title, MAX_PUSH_TITLE_CHARS); const subtitle = body.subtitle == null ? "" : boundedString(body.subtitle, MAX_PUSH_SUBTITLE_CHARS); const text = boundedString(body.body, MAX_PUSH_BODY_CHARS); const workspaceId = body.workspaceId == null ? "" : boundedString(body.workspaceId, MAX_PUSH_ID_CHARS); const surfaceId = body.surfaceId == null ? "" : boundedString(body.surfaceId, MAX_PUSH_ID_CHARS); + const notificationId = body.notificationId == null ? "" : boundedString(body.notificationId, MAX_PUSH_ID_CHARS); if (title == null) return { ok: false, error: "title_too_long" }; if (subtitle == null) return { ok: false, error: "subtitle_too_long" }; if (text == null) return { ok: false, error: "body_too_long" }; if (workspaceId == null) return { ok: false, error: "workspace_id_too_long" }; if (surfaceId == null) return { ok: false, error: "surface_id_too_long" }; - if (!title && !text) return { ok: false, error: "empty_notification" }; + if (notificationId == null) return { ok: false, error: "notification_id_too_long" }; + // A dismiss push is banner-less by design; only the visible kind needs text. + if (kind === "notify" && !title && !text) return { ok: false, error: "empty_notification" }; + + const dismissedIds = parseDismissedIds(body.notificationIds); + if (!dismissedIds.ok) return { ok: false, error: dismissedIds.error }; + if (kind === "dismiss" && dismissedIds.value.length === 0) { + return { ok: false, error: "missing_dismissed_ids" }; + } return { ok: true, value: { + kind, title, subtitle: subtitle || null, body: text, workspaceId: workspaceId || null, surfaceId: surfaceId || null, + notificationId: notificationId || null, + dismissedIds: kind === "dismiss" ? dismissedIds.value : [], + badgeCount: parseBadgeCount(body.badgeCount), hideContent: body.hideContent === true, }, }; } +/** + * The badge count if usable, else `null` ("leave the badge alone"). Tolerant on + * purpose: the badge is an enhancement, and a malformed count from an old or + * odd client must not fail the whole push. Clamped to a sane ceiling so a + * buggy sender cannot render a runaway number. + */ +function parseBadgeCount(value: unknown): number | null { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return null; + return Math.min(value, MAX_PUSH_BADGE_COUNT); +} + +function parseDismissedIds( + value: unknown, +): { readonly ok: true; readonly value: readonly string[] } | { readonly ok: false; readonly error: string } { + if (value == null) return { ok: true, value: [] }; + if (!Array.isArray(value)) return { ok: false, error: "bad_notification_ids" }; + if (value.length > MAX_PUSH_DISMISS_IDS) return { ok: false, error: "too_many_notification_ids" }; + const ids: string[] = []; + for (const entry of value) { + const id = boundedString(entry, MAX_PUSH_ID_CHARS); + if (id == null) return { ok: false, error: "notification_id_too_long" }; + if (id) ids.push(id); + } + return { ok: true, value: ids }; +} + export async function readBoundedJsonObject( request: Request, maxBytes: number, diff --git a/web/services/apns/sender.ts b/web/services/apns/sender.ts index 6edad205f8c..cdc081a9c4a 100644 --- a/web/services/apns/sender.ts +++ b/web/services/apns/sender.ts @@ -104,6 +104,22 @@ export async function sendApnsNotification( if (targets.length === 0) return []; const jwt = providerToken(config); const body = Buffer.from(JSON.stringify(buildApnsPayload(input))); + // The collapse-id coalesces repeated updates for the same notification into + // one delivered banner (the dismiss lever itself is the `cmux.notificationId` + // payload key, which iOS maps to delivered banners; the request identifier + // equaling the collapse-id is observed OS behavior, not a contract). APNs + // caps it at 64 bytes; a UUID is 36, but guard anyway so an over-long id + // degrades to "no collapse" instead of a 400. + // Never set on a dismiss push: a collapse would try to REPLACE the delivered + // banner with the invisible dismiss payload instead of leaving removal to the + // app's background handler. + const collapseId = input.kind === "dismiss" ? undefined : collapseIdFor(input.notificationId); + // A dismiss push carries badge + content-available but nothing visible: + // priority 5 (power-friendly, may coalesce) instead of the default 10, which + // Apple reserves for pushes that present UI immediately. Still push-type + // `alert` because a badge update is user-facing in Apple's taxonomy and a + // `background`-type push may not carry `badge`. + const priority = input.kind === "dismiss" ? "5" : undefined; const byHost = new Map(); for (const t of targets) { @@ -113,7 +129,7 @@ export async function sendApnsNotification( const results = await Promise.all( [...byHost.entries()].map(([host, hostTargets]) => - sendHostGroup(transport, host, hostTargets, jwt, body, timeoutMs).catch(() => + sendHostGroup(transport, host, hostTargets, jwt, body, timeoutMs, collapseId, priority).catch(() => connectionErrorResults(hostTargets), ), ), @@ -121,6 +137,13 @@ export async function sendApnsNotification( return results.flat(); } +/** A valid (≤64-byte) apns-collapse-id for the notification id, or undefined. */ +function collapseIdFor(notificationId: string | null | undefined): string | undefined { + const id = notificationId?.trim(); + if (!id) return undefined; + return Buffer.byteLength(id, "utf8") <= 64 ? id : undefined; +} + function connectionErrorResults(hostTargets: readonly ApnsTarget[]): ApnsSendResult[] { return hostTargets.map((target) => ({ deviceToken: target.deviceToken, @@ -137,6 +160,8 @@ async function sendHostGroup( jwt: string, body: Buffer, timeoutMs: number, + collapseId: string | undefined, + priority: string | undefined, ): Promise { let client: ApnsHttp2Session | null = null; try { @@ -146,7 +171,9 @@ async function sendHostGroup( const connError: Promise = new Promise((resolve) => { connectedClient.once("error", () => resolve(null)); }); - return await Promise.all(hostTargets.map((t) => sendOne(connectedClient, jwt, t, body, timeoutMs, connError))); + return await Promise.all( + hostTargets.map((t) => sendOne(connectedClient, jwt, t, body, timeoutMs, connError, collapseId, priority)), + ); } catch { return connectionErrorResults(hostTargets); } finally { @@ -161,6 +188,8 @@ function sendOne( body: Buffer, timeoutMs: number, connError: Promise, + collapseId: string | undefined, + priority: string | undefined, ): Promise { return new Promise((resolve) => { let settled = false; @@ -173,7 +202,7 @@ function sendOne( let req: http2.ClientHttp2Stream; try { - req = client.request({ + const headers: http2.OutgoingHttpHeaders = { ":method": "POST", ":path": `/3/device/${target.deviceToken}`, "apns-topic": target.bundleId, @@ -181,7 +210,12 @@ function sendOne( authorization: `bearer ${jwt}`, "content-type": "application/json", "content-length": String(body.length), - }); + }; + // Collapses repeated updates for the same notification into one + // delivered banner. + if (collapseId) headers["apns-collapse-id"] = collapseId; + if (priority) headers["apns-priority"] = priority; + req = client.request(headers); } catch (err) { finish(0, err instanceof Error ? err.message : "request_error"); return; diff --git a/web/tests/apns.test.ts b/web/tests/apns.test.ts index badf6a7a9ae..40ecede89f3 100644 --- a/web/tests/apns.test.ts +++ b/web/tests/apns.test.ts @@ -1,15 +1,20 @@ import crypto from "node:crypto"; import { EventEmitter } from "node:events"; +import type http2 from "node:http2"; import { describe, expect, test } from "bun:test"; import { apnsHostForEnvironment, buildApnsPayload, + CMUX_APNS_CATEGORY, shouldPruneToken, } from "../services/apns/payload"; import { summarizeApnsSendResults } from "../services/apns/response"; import { sendApnsNotification, signApnsJwt, normalizeP8 } from "../services/apns/sender"; import { + MAX_PUSH_BADGE_COUNT, MAX_PUSH_BODY_CHARS, + MAX_PUSH_DISMISS_IDS, + MAX_PUSH_ID_CHARS, normalizeApnsBundle, parsePushPayload, readBoundedJsonObject, @@ -36,6 +41,33 @@ describe("apns payload", () => { expect("cmux" in payload).toBe(false); }); + test("carries the stable notification id and dismiss-sync category", () => { + const payload = buildApnsPayload({ + title: "claude", + body: "Agent finished", + workspaceId: "ws-1", + notificationId: "n-42", + }) as { aps: Record; cmux: Record }; + + // The category is what arms iOS customDismissAction; the cmux key lets an + // iOS swipe tell the Mac which notification was dismissed. + expect(payload.aps.category).toBe(CMUX_APNS_CATEGORY); + expect(payload.cmux).toEqual({ workspaceId: "ws-1", notificationId: "n-42" }); + }); + + test("keeps the notification id even when content is hidden (id is not content)", () => { + const payload = buildApnsPayload({ + title: "secret", + body: "secret output", + notificationId: "n-9", + hideContent: true, + }) as { aps: { alert: Record; category: string }; cmux: Record }; + + expect(payload.aps.alert.title).toBe("cmux"); + expect(payload.aps.category).toBe(CMUX_APNS_CATEGORY); + expect(payload.cmux).toEqual({ notificationId: "n-9" }); + }); + test("hideContent redacts terminal content but keeps a generic compatibility body and deep-link", () => { const payload = buildApnsPayload({ title: "secret-host", @@ -55,6 +87,37 @@ describe("apns payload", () => { const payload = buildApnsPayload({ title: " ", body: "b" }) as { aps: { alert: { title: string } } }; expect(payload.aps.alert.title).toBe("cmux"); }); + + test("stamps aps.badge with the authoritative unread count on a notify push", () => { + const payload = buildApnsPayload({ + title: "claude", + body: "Agent finished", + badgeCount: 3, + }) as { aps: Record }; + + expect(payload.aps.badge).toBe(3); + }); + + test("leaves the badge alone when no count was sent (older Macs)", () => { + const payload = buildApnsPayload({ title: "t", body: "b" }) as { aps: Record }; + expect("badge" in payload.aps).toBe(false); + }); + + test("dismiss push is banner-less: content-available + badge + dismissed ids only", () => { + const payload = buildApnsPayload({ + kind: "dismiss", + title: "", + body: "", + dismissedIds: ["n-1", "n-2"], + badgeCount: 0, + }) as { aps: Record; cmux: Record }; + + expect(payload.aps).toEqual({ "content-available": 1, badge: 0 }); + // Nothing visible: no alert, no sound, no category. + expect("alert" in payload.aps).toBe(false); + expect("sound" in payload.aps).toBe(false); + expect(payload.cmux).toEqual({ dismissedIds: ["n-1", "n-2"] }); + }); }); describe("apns host + pruning", () => { @@ -119,17 +182,22 @@ describe("apns route policy", () => { body: " done ", workspaceId: " ws-1 ", surfaceId: " sf-1 ", + notificationId: " n-1 ", hideContent: true, }); expect(parsed).toEqual({ ok: true, value: { + kind: "notify", title: "agent", subtitle: "workspace", body: "done", workspaceId: "ws-1", surfaceId: "sf-1", + notificationId: "n-1", + dismissedIds: [], + badgeCount: null, hideContent: true, }, }); @@ -144,6 +212,87 @@ describe("apns route policy", () => { }); }); + test("absent notificationId parses to null and over-long is rejected", () => { + const parsed = parsePushPayload({ title: "agent", body: "done" }); + expect(parsed).toEqual({ + ok: true, + value: { + kind: "notify", + title: "agent", + subtitle: null, + body: "done", + workspaceId: null, + surfaceId: null, + notificationId: null, + dismissedIds: [], + badgeCount: null, + hideContent: false, + }, + }); + + expect( + parsePushPayload({ title: "agent", body: "done", notificationId: "x".repeat(MAX_PUSH_ID_CHARS + 1) }), + ).toEqual({ ok: false, error: "notification_id_too_long" }); + }); + + test("parses a dismiss push: text-free, requires ids, carries the badge", () => { + const parsed = parsePushPayload({ + kind: "dismiss", + notificationIds: [" n-1 ", "n-2"], + badgeCount: 4, + }); + + expect(parsed).toEqual({ + ok: true, + value: { + kind: "dismiss", + title: "", + subtitle: null, + body: "", + workspaceId: null, + surfaceId: null, + notificationId: null, + dismissedIds: ["n-1", "n-2"], + badgeCount: 4, + hideContent: false, + }, + }); + + expect(parsePushPayload({ kind: "dismiss", badgeCount: 0 })).toEqual({ + ok: false, + error: "missing_dismissed_ids", + }); + expect(parsePushPayload({ kind: "dismiss", notificationIds: "n-1" })).toEqual({ + ok: false, + error: "bad_notification_ids", + }); + expect( + parsePushPayload({ + kind: "dismiss", + notificationIds: Array.from({ length: MAX_PUSH_DISMISS_IDS + 1 }, (_, i) => `n-${i}`), + }), + ).toEqual({ ok: false, error: "too_many_notification_ids" }); + expect( + parsePushPayload({ kind: "dismiss", notificationIds: ["x".repeat(MAX_PUSH_ID_CHARS + 1)] }), + ).toEqual({ ok: false, error: "notification_id_too_long" }); + }); + + test("badge count is tolerant: malformed is ignored, runaway is clamped", () => { + const value = (badgeCount: unknown) => { + const parsed = parsePushPayload({ title: "agent", body: "done", badgeCount }); + if (!parsed.ok) throw new Error(parsed.error); + return parsed.value.badgeCount; + }; + + expect(value(7)).toBe(7); + expect(value(0)).toBe(0); + expect(value(undefined)).toBeNull(); + expect(value("7")).toBeNull(); + expect(value(-1)).toBeNull(); + expect(value(1.5)).toBeNull(); + expect(value(MAX_PUSH_BADGE_COUNT + 100)).toBe(MAX_PUSH_BADGE_COUNT); + }); + test("reads only bounded JSON objects from requests", async () => { await expect( readBoundedJsonObject( @@ -434,4 +583,190 @@ describe("apns sender transport", () => { ]); expect(closed).toEqual([productionHost]); }); + + test("stamps apns-collapse-id from the notification id so the banner is dismiss-syncable", async () => { + const capturedHeaders: http2.OutgoingHttpHeaders[] = []; + + class FakeRequest extends EventEmitter { + setTimeout() { + return this; + } + close() { + return this; + } + end() { + this.emit("response", { ":status": 200 }); + this.emit("end"); + return this; + } + } + + class FakeSession extends EventEmitter { + request(headers: http2.OutgoingHttpHeaders) { + capturedHeaders.push(headers); + return new FakeRequest(); + } + close() {} + } + + const transport = { + connect: () => new FakeSession(), + } as unknown as Parameters[4]; + + const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const p8 = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + + await sendApnsNotification( + { keyP8: p8, keyId: "KID-COLLAPSE", teamId: "TEAM456" }, + [{ deviceToken: "a".repeat(64), bundleId: "com.cmuxterm.app", environment: "production" }], + { title: "agent", body: "done", notificationId: "n-7" }, + 1000, + transport, + ); + + expect(capturedHeaders).toHaveLength(1); + expect(capturedHeaders[0]["apns-collapse-id"]).toBe("n-7"); + }); + + test("omits apns-collapse-id when there is no notification id", async () => { + const capturedHeaders: http2.OutgoingHttpHeaders[] = []; + + class FakeRequest extends EventEmitter { + setTimeout() { + return this; + } + close() { + return this; + } + end() { + this.emit("response", { ":status": 200 }); + this.emit("end"); + return this; + } + } + + class FakeSession extends EventEmitter { + request(headers: http2.OutgoingHttpHeaders) { + capturedHeaders.push(headers); + return new FakeRequest(); + } + close() {} + } + + const transport = { + connect: () => new FakeSession(), + } as unknown as Parameters[4]; + + const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const p8 = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + + await sendApnsNotification( + { keyP8: p8, keyId: "KID-NO-COLLAPSE", teamId: "TEAM456" }, + [{ deviceToken: "a".repeat(64), bundleId: "com.cmuxterm.app", environment: "production" }], + { title: "agent", body: "done" }, + 1000, + transport, + ); + + expect(capturedHeaders).toHaveLength(1); + expect("apns-collapse-id" in capturedHeaders[0]).toBe(false); + }); + + test("dismiss push: never collapses onto the banner and downgrades to priority 5", async () => { + const capturedHeaders: http2.OutgoingHttpHeaders[] = []; + + class FakeRequest extends EventEmitter { + setTimeout() { + return this; + } + close() { + return this; + } + end() { + this.emit("response", { ":status": 200 }); + this.emit("end"); + return this; + } + } + + class FakeSession extends EventEmitter { + request(headers: http2.OutgoingHttpHeaders) { + capturedHeaders.push(headers); + return new FakeRequest(); + } + close() {} + } + + const transport = { + connect: () => new FakeSession(), + } as unknown as Parameters[4]; + + const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const p8 = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + + await sendApnsNotification( + { keyP8: p8, keyId: "KID-DISMISS", teamId: "TEAM456" }, + [{ deviceToken: "a".repeat(64), bundleId: "com.cmuxterm.app", environment: "production" }], + { + kind: "dismiss", + title: "", + body: "", + // notificationId would normally collapse; a dismiss push must NOT, + // or APNs would replace the visible banner with the silent payload. + notificationId: "n-7", + dismissedIds: ["n-7"], + badgeCount: 0, + }, + 1000, + transport, + ); + + expect(capturedHeaders).toHaveLength(1); + expect("apns-collapse-id" in capturedHeaders[0]).toBe(false); + expect(capturedHeaders[0]["apns-priority"]).toBe("5"); + }); + + test("notify push keeps the default immediate priority", async () => { + const capturedHeaders: http2.OutgoingHttpHeaders[] = []; + + class FakeRequest extends EventEmitter { + setTimeout() { + return this; + } + close() { + return this; + } + end() { + this.emit("response", { ":status": 200 }); + this.emit("end"); + return this; + } + } + + class FakeSession extends EventEmitter { + request(headers: http2.OutgoingHttpHeaders) { + capturedHeaders.push(headers); + return new FakeRequest(); + } + close() {} + } + + const transport = { + connect: () => new FakeSession(), + } as unknown as Parameters[4]; + + const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const p8 = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + + await sendApnsNotification( + { keyP8: p8, keyId: "KID-NOTIFY-PRIO", teamId: "TEAM456" }, + [{ deviceToken: "a".repeat(64), bundleId: "com.cmuxterm.app", environment: "production" }], + { title: "agent", body: "done", badgeCount: 2 }, + 1000, + transport, + ); + + expect(capturedHeaders).toHaveLength(1); + expect("apns-priority" in capturedHeaders[0]).toBe(false); + }); }); From d759c83ca26797c167520af65a91078469ff74e3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:13:42 -0700 Subject: [PATCH 21/31] Satisfy notification sync policy checks --- .../DeliveredNotificationClearing.swift | 13 -------- ...ellComposite+NotificationDismissSync.swift | 13 ++++++++ .../NoopDeliveredNotificationClearer.swift | 12 +++++++ .../MobileShellDismissSyncTests.swift | 25 --------------- ...ecordingDeliveredNotificationClearer.swift | 26 +++++++++++++++ Sources/Cloud/PhonePushClient.swift | 32 ++----------------- Sources/Cloud/PhonePushPayload.swift | 27 ++++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 +++ 8 files changed, 85 insertions(+), 67 deletions(-) create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/NoopDeliveredNotificationClearer.swift create mode 100644 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/RecordingDeliveredNotificationClearer.swift create mode 100644 Sources/Cloud/PhonePushPayload.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift index 3960e4c57b1..348d512f8ac 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift @@ -36,16 +36,3 @@ public protocol DeliveredNotificationClearing: Sendable { /// - Parameter count: The unread total; clamped to zero by conformers. func setBadgeCount(_ count: Int) } - -/// No-op ``DeliveredNotificationClearing`` for preview stores. -/// -/// A preview/test store must never mutate the real system notification -/// center or app badge, and `UNUserNotificationCenter.current()` traps in -/// processes without a bundle proxy (e.g. `swift test`), so -/// ``MobileShellComposite/preview(runtime:)`` injects this instead of -/// ``SystemDeliveredNotificationClearer``. -struct NoopDeliveredNotificationClearer: DeliveredNotificationClearing { - func removeDelivered(ids: [String]) async {} - func deliveredIdentifiers() async -> [String] { [] } - func setBadgeCount(_ count: Int) {} -} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift index f8c9f74f60e..e7ec22f3399 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift @@ -9,6 +9,11 @@ private let mobileShellLog = Logger( ) extension MobileShellComposite { + /// Enqueue and send phone-side notification dismissals to the connected Mac. + /// + /// IDs are stable Mac notification identifiers from `cmux.notificationId`. + /// They are stored before the RPC and removed only after the Mac confirms, + /// so a dropped connection flushes them on the next successful subscribe. public func dismissNotification(ids: [String]) async { let trimmed = ids .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -37,6 +42,10 @@ extension MobileShellComposite { await dismissNotification(ids: pending) } + /// Clear delivered iOS banners for Mac notification identifiers. + /// + /// Called from live `notification.dismissed` events and foreground reconcile + /// responses so Mac-side reads/removals clear mirrored phone banners. public func clearDeliveredNotifications(ids: [String]) async { let trimmed = ids .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -45,6 +54,10 @@ extension MobileShellComposite { await deliveredNotificationClearer.removeDelivered(ids: trimmed) } + /// Set the phone app icon badge to the Mac's authoritative unread total. + /// + /// The badge is absolute, not locally incremented/decremented, so drift + /// self-heals on the next event, push, or reconcile response. public func applyAuthoritativeUnreadBadge(_ count: Int) { deliveredNotificationClearer.setBadgeCount(max(0, count)) } diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/NoopDeliveredNotificationClearer.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/NoopDeliveredNotificationClearer.swift new file mode 100644 index 00000000000..776907a4060 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/NoopDeliveredNotificationClearer.swift @@ -0,0 +1,12 @@ +/// No-op ``DeliveredNotificationClearing`` for preview stores. +/// +/// A preview/test store must never mutate the real system notification +/// center or app badge, and `UNUserNotificationCenter.current()` traps in +/// processes without a bundle proxy (e.g. `swift test`), so +/// ``MobileShellComposite/preview(runtime:)`` injects this instead of +/// ``SystemDeliveredNotificationClearer``. +struct NoopDeliveredNotificationClearer: DeliveredNotificationClearing { + func removeDelivered(ids: [String]) async {} + func deliveredIdentifiers() async -> [String] { [] } + func setBadgeCount(_ count: Int) {} +} diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift index 8a9bd08e3a8..e13ac5e43ca 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift @@ -6,31 +6,6 @@ import Testing import UserNotifications @testable import CmuxMobileShell -@MainActor -private final class RecordingDeliveredNotificationClearer: DeliveredNotificationClearing { - private(set) var clearedIDs: [[String]] = [] - private(set) var badgeCounts: [Int] = [] - var deliveredIDs: [String] = [] - - nonisolated init() {} - - nonisolated func removeDelivered(ids: [String]) async { - await MainActor.run { - clearedIDs.append(ids) - } - } - - nonisolated func deliveredIdentifiers() async -> [String] { - await MainActor.run { deliveredIDs } - } - - nonisolated func setBadgeCount(_ count: Int) { - MainActor.assumeIsolated { - badgeCounts.append(count) - } - } -} - @MainActor @Suite struct MobileShellDismissSyncTests { private func makeStore( diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/RecordingDeliveredNotificationClearer.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/RecordingDeliveredNotificationClearer.swift new file mode 100644 index 00000000000..1b84808108e --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/RecordingDeliveredNotificationClearer.swift @@ -0,0 +1,26 @@ +import CmuxMobileShell + +@MainActor +final class RecordingDeliveredNotificationClearer: DeliveredNotificationClearing { + private(set) var clearedIDs: [[String]] = [] + private(set) var badgeCounts: [Int] = [] + var deliveredIDs: [String] = [] + + nonisolated init() {} + + nonisolated func removeDelivered(ids: [String]) async { + await MainActor.run { + clearedIDs.append(ids) + } + } + + nonisolated func deliveredIdentifiers() async -> [String] { + await MainActor.run { deliveredIDs } + } + + nonisolated func setBadgeCount(_ count: Int) { + MainActor.assumeIsolated { + badgeCounts.append(count) + } + } +} diff --git a/Sources/Cloud/PhonePushClient.swift b/Sources/Cloud/PhonePushClient.swift index a1578d2e13b..dccda152d4d 100644 --- a/Sources/Cloud/PhonePushClient.swift +++ b/Sources/Cloud/PhonePushClient.swift @@ -152,7 +152,7 @@ final class PhonePushClient { lastSentAt[key] = now let hideContent = UserDefaults.standard.bool(forKey: PhonePushSettings.hideContentKey) - let payload = Payload( + let payload = PhonePushPayload( kind: .notify, title: notification.title, subtitle: notification.subtitle, @@ -194,7 +194,7 @@ final class PhonePushClient { let deduped = pendingDismissedIDs.filter { seen.insert($0).inserted } let chunk = Array(deduped.prefix(Self.maxDismissIDsPerPush)) pendingDismissedIDs = Array(deduped.dropFirst(Self.maxDismissIDsPerPush)) - await send(Payload( + await send(PhonePushPayload( kind: .dismiss, title: "", subtitle: "", @@ -209,33 +209,7 @@ final class PhonePushClient { } } - private struct Payload: Sendable { - enum Kind: String, Sendable { - /// Visible banner mirror of a Mac notification. - case notify - /// Silent banner-removal + badge push (Mac-side dismiss, cold lane). - case dismiss - } - - let kind: Kind - let title: String - let subtitle: String - let body: String - let workspaceId: String? - let surfaceId: String? - /// Stable notification id (the Mac store ``TerminalNotification/id``). - /// Travels to APNs as both an `apns-collapse-id` (so a later Mac→iOS - /// dismiss can target the delivered banner) and `cmux.notificationId` - /// (so an iOS swipe can tell the Mac which notification was dismissed). - let notificationId: String? - /// The dismissed ids a `.dismiss` push carries (else empty). - let notificationIds: [String] - /// Authoritative unread total at send time, emitted as `aps.badge`. - let badgeCount: Int - let hideContent: Bool - } - - private func send(_ payload: Payload) async { + private func send(_ payload: PhonePushPayload) async { guard let auth else { return } let tokens: (accessToken: String, refreshToken: String) do { diff --git a/Sources/Cloud/PhonePushPayload.swift b/Sources/Cloud/PhonePushPayload.swift new file mode 100644 index 00000000000..39cee8359c1 --- /dev/null +++ b/Sources/Cloud/PhonePushPayload.swift @@ -0,0 +1,27 @@ +import Foundation + +struct PhonePushPayload: Sendable { + enum Kind: String, Sendable { + /// Visible banner mirror of a Mac notification. + case notify + /// Silent banner-removal + badge push (Mac-side dismiss, cold lane). + case dismiss + } + + let kind: Kind + let title: String + let subtitle: String + let body: String + let workspaceId: String? + let surfaceId: String? + /// Stable notification id (the Mac store ``TerminalNotification/id``). + /// Travels to APNs as both an `apns-collapse-id` (so a later Mac->iOS + /// dismiss can target the delivered banner) and `cmux.notificationId` + /// (so an iOS swipe can tell the Mac which notification was dismissed). + let notificationId: String? + /// The dismissed ids a `.dismiss` push carries (else empty). + let notificationIds: [String] + /// Authoritative unread total at send time, emitted as `aps.badge`. + let badgeCount: Int + let hideContent: Bool +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 939ceaf0644..61fdcf46dc3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -487,6 +487,7 @@ A5001425 /* PDFPreviewChromeDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001424 /* PDFPreviewChromeDebugWindowController.swift */; }; D1F0A00400000000000000D1 /* PhoneForwardingMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00400000000000000D2 /* PhoneForwardingMode.swift */; }; D1F0A00100000000000000A1 /* PhonePushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000A2 /* PhonePushClient.swift */; }; + D1F0A00100000000000000A3 /* PhonePushPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000A4 /* PhonePushPayload.swift */; }; D1F0A00300000000000000C1 /* PhonePushPresenceGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00300000000000000C2 /* PhonePushPresenceGateTests.swift */; }; B3575000000000000000000A /* PiVaultAgentPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */; }; D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */; }; @@ -1303,6 +1304,7 @@ A5001424 /* PDFPreviewChromeDebugWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PDFPreviewChromeDebugWindowController.swift; sourceTree = ""; }; D1F0A00400000000000000D2 /* PhoneForwardingMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneForwardingMode.swift; sourceTree = ""; }; D1F0A00100000000000000A2 /* PhonePushClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PhonePushClient.swift; sourceTree = ""; }; + D1F0A00100000000000000A4 /* PhonePushPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PhonePushPayload.swift; sourceTree = ""; }; D1F0A00300000000000000C2 /* PhonePushPresenceGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhonePushPresenceGateTests.swift; sourceTree = ""; }; B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiVaultAgentPersistenceTests.swift; sourceTree = ""; }; D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalTabDragRoutingTests.swift; sourceTree = ""; }; @@ -1791,6 +1793,7 @@ children = ( 9CEC6EF35B71AE59FA45BA69 /* VMClient.swift */, D1F0A00100000000000000A2 /* PhonePushClient.swift */, + D1F0A00100000000000000A4 /* PhonePushPayload.swift */, D1F0A00400000000000000D2 /* PhoneForwardingMode.swift */, DE71CE000000000000000003 /* DeviceRegistryClient.swift */, 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */, @@ -3165,6 +3168,7 @@ A5001425 /* PDFPreviewChromeDebugWindowController.swift in Sources */, D1F0A00400000000000000D1 /* PhoneForwardingMode.swift in Sources */, D1F0A00100000000000000A1 /* PhonePushClient.swift in Sources */, + D1F0A00100000000000000A3 /* PhonePushPayload.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001521 /* PostHogAnalytics.swift in Sources */, C47110020000000000000001 /* ProcessPipeReader.swift in Sources */, From f7043f1fc5e4ead23044f3fcf35a2f620ef8e6f4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:21:12 -0700 Subject: [PATCH 22/31] Keep email out of compact pairing payload --- .../CMUXMobileCore/CompactAttachTicket.swift | 15 +++++++++++++-- .../CmxAttachTicketCompactCoderTests.swift | 19 +++++++++++++++++-- .../CmxAttachTicketInputTests.swift | 4 +++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift index caa6475efab..5b3ed9b899e 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift @@ -23,7 +23,7 @@ struct CompactAttachTicket: Codable { w = Self.normalizedNonEmpty(ticket.workspaceID) t = Self.normalizedNonEmpty(ticket.terminalID) d = ticket.macDeviceID - u = Self.normalizedNonEmpty(ticket.macUserEmail) + u = Self.normalizedNonEmpty(ticket.macUserID) pc = ticket.macPairingCompatibilityVersion av = Self.normalizedNonEmpty(ticket.macAppVersion) ab = Self.normalizedNonEmpty(ticket.macAppBuild) @@ -37,7 +37,8 @@ struct CompactAttachTicket: Codable { terminalID: t, macDeviceID: d, macDisplayName: nil, - macUserEmail: u, + macUserEmail: Self.legacyEmail(from: u), + macUserID: Self.opaqueUserID(from: u), macPairingCompatibilityVersion: pc, macAppVersion: av, macAppBuild: ab, @@ -52,6 +53,16 @@ struct CompactAttachTicket: Codable { } return value } + + private static func legacyEmail(from value: String?) -> String? { + guard let value, value.contains("@") else { return nil } + return value + } + + private static func opaqueUserID(from value: String?) -> String? { + guard let value, !value.contains("@") else { return nil } + return value + } } private extension CompactAttachTicket { /// Encode routes, omitting each route id the decoder can resynthesize diff --git a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift index df5f259f84c..e5828652089 100644 --- a/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift +++ b/Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift @@ -42,6 +42,7 @@ private func legacyDecoder() -> JSONDecoder { macDeviceID: "mac-1", macDisplayName: "Studio", macUserEmail: "user@example.com", + macUserID: "user_mac_123", macPairingCompatibilityVersion: 1, macAppVersion: "0.64.15", macAppBuild: "42", @@ -61,7 +62,8 @@ 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("user@example.com")) + #expect(json.contains("\"u\":\"user_mac_123\"")) #expect(json.contains("\"pc\":1")) #expect(json.contains("\"av\":\"0.64.15\"")) #expect(json.contains("\"ab\":\"42\"")) @@ -103,6 +105,7 @@ private func legacyDecoder() -> JSONDecoder { macDeviceID: "mac-1", macDisplayName: "Studio", macUserEmail: "user@example.com", + macUserID: "user_mac_123", macPairingCompatibilityVersion: 1, macAppVersion: "0.64.15", macAppBuild: "42", @@ -119,7 +122,8 @@ 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.macUserEmail == nil) + #expect(decoded.macUserID == ticket.macUserID) #expect(decoded.macPairingCompatibilityVersion == ticket.macPairingCompatibilityVersion) #expect(decoded.macAppVersion == ticket.macAppVersion) #expect(decoded.macAppBuild == ticket.macAppBuild) @@ -134,6 +138,17 @@ private func legacyDecoder() -> JSONDecoder { #expect(!decoded.isExpired(at: .distantFuture)) } +@Test func compactDecodeKeepsLegacyEmailPayloadsWorking() throws { + let legacyEmailPayload = """ + {"v":1,"d":"mac-1","u":"user@example.com","r":[{"k":"tailscale","e":{"h":"100.64.1.2","p":49831}}]} + """ + + let decoded = try compactCoder.decode(Data(legacyEmailPayload.utf8)) + + #expect(decoded.macUserEmail == "user@example.com") + #expect(decoded.macUserID == nil) +} + @Test func compactRoundTripsMacWidePairingTicketAndDropsEmptyFields() throws { // The shape the pairing window mints: Mac-wide (empty workspaceID), no // terminal scope. diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift index c03435a2658..3bf4a50dd5a 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift @@ -15,6 +15,7 @@ import Testing macDeviceID: "mac-1", macDisplayName: "Studio", macUserEmail: "user@example.com", + macUserID: "user_mac_123", macPairingCompatibilityVersion: 1, macAppVersion: "0.64.15", macAppBuild: "42", @@ -45,7 +46,8 @@ import Testing let decoded = try CmxAttachTicketInput.decode(url) #expect(decoded.macDeviceID == "mac-1") - #expect(decoded.macUserEmail == "user@example.com") + #expect(decoded.macUserEmail == nil) + #expect(decoded.macUserID == "user_mac_123") #expect(decoded.macPairingCompatibilityVersion == 1) #expect(decoded.macAppVersion == "0.64.15") #expect(decoded.macAppBuild == "42") From 38f745ea283e5ad385110f70e66a0b8afcd066d3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:32:49 -0700 Subject: [PATCH 23/31] Satisfy pairing payload policy checks --- .../CMUXMobileCore/CompactAttachTicket.swift | 22 ++++++++++--------- Sources/Cloud/PhonePushPayload.swift | 9 +------- Sources/Cloud/PhonePushPayloadKind.swift | 8 +++++++ cmux.xcodeproj/project.pbxproj | 4 ++++ 4 files changed, 25 insertions(+), 18 deletions(-) create mode 100644 Sources/Cloud/PhonePushPayloadKind.swift diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift index 5b3ed9b899e..ce313999190 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift @@ -37,8 +37,8 @@ struct CompactAttachTicket: Codable { terminalID: t, macDeviceID: d, macDisplayName: nil, - macUserEmail: Self.legacyEmail(from: u), - macUserID: Self.opaqueUserID(from: u), + macUserEmail: legacyCompactEmail(from: u), + macUserID: compactOpaqueUserID(from: u), macPairingCompatibilityVersion: pc, macAppVersion: av, macAppBuild: ab, @@ -54,16 +54,18 @@ struct CompactAttachTicket: Codable { return value } - private static func legacyEmail(from value: String?) -> String? { - guard let value, value.contains("@") else { return nil } - return value - } +} - private static func opaqueUserID(from value: String?) -> String? { - guard let value, !value.contains("@") else { return nil } - return value - } +private func legacyCompactEmail(from value: String?) -> String? { + guard let value, value.contains("@") else { return nil } + return value +} + +private func compactOpaqueUserID(from value: String?) -> String? { + guard let value, !value.contains("@") else { return nil } + return value } + private extension CompactAttachTicket { /// Encode routes, omitting each route id the decoder can resynthesize /// (`kind` for the first route of a kind, `kind_N` for the Nth; exactly diff --git a/Sources/Cloud/PhonePushPayload.swift b/Sources/Cloud/PhonePushPayload.swift index 39cee8359c1..4b818703375 100644 --- a/Sources/Cloud/PhonePushPayload.swift +++ b/Sources/Cloud/PhonePushPayload.swift @@ -1,14 +1,7 @@ import Foundation struct PhonePushPayload: Sendable { - enum Kind: String, Sendable { - /// Visible banner mirror of a Mac notification. - case notify - /// Silent banner-removal + badge push (Mac-side dismiss, cold lane). - case dismiss - } - - let kind: Kind + let kind: PhonePushPayloadKind let title: String let subtitle: String let body: String diff --git a/Sources/Cloud/PhonePushPayloadKind.swift b/Sources/Cloud/PhonePushPayloadKind.swift new file mode 100644 index 00000000000..ff7da263732 --- /dev/null +++ b/Sources/Cloud/PhonePushPayloadKind.swift @@ -0,0 +1,8 @@ +import Foundation + +enum PhonePushPayloadKind: String, Sendable { + /// Visible banner mirror of a Mac notification. + case notify + /// Silent banner-removal + badge push (Mac-side dismiss, cold lane). + case dismiss +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 61fdcf46dc3..e88a1c9ce88 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -488,6 +488,7 @@ D1F0A00400000000000000D1 /* PhoneForwardingMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00400000000000000D2 /* PhoneForwardingMode.swift */; }; D1F0A00100000000000000A1 /* PhonePushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000A2 /* PhonePushClient.swift */; }; D1F0A00100000000000000A3 /* PhonePushPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000A4 /* PhonePushPayload.swift */; }; + D1F0A00100000000000000A5 /* PhonePushPayloadKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00100000000000000A6 /* PhonePushPayloadKind.swift */; }; D1F0A00300000000000000C1 /* PhonePushPresenceGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00300000000000000C2 /* PhonePushPresenceGateTests.swift */; }; B3575000000000000000000A /* PiVaultAgentPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */; }; D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */; }; @@ -1305,6 +1306,7 @@ D1F0A00400000000000000D2 /* PhoneForwardingMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneForwardingMode.swift; sourceTree = ""; }; D1F0A00100000000000000A2 /* PhonePushClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PhonePushClient.swift; sourceTree = ""; }; D1F0A00100000000000000A4 /* PhonePushPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PhonePushPayload.swift; sourceTree = ""; }; + D1F0A00100000000000000A6 /* PhonePushPayloadKind.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PhonePushPayloadKind.swift; sourceTree = ""; }; D1F0A00300000000000000C2 /* PhonePushPresenceGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhonePushPresenceGateTests.swift; sourceTree = ""; }; B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiVaultAgentPersistenceTests.swift; sourceTree = ""; }; D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalTabDragRoutingTests.swift; sourceTree = ""; }; @@ -1794,6 +1796,7 @@ 9CEC6EF35B71AE59FA45BA69 /* VMClient.swift */, D1F0A00100000000000000A2 /* PhonePushClient.swift */, D1F0A00100000000000000A4 /* PhonePushPayload.swift */, + D1F0A00100000000000000A6 /* PhonePushPayloadKind.swift */, D1F0A00400000000000000D2 /* PhoneForwardingMode.swift */, DE71CE000000000000000003 /* DeviceRegistryClient.swift */, 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */, @@ -3169,6 +3172,7 @@ D1F0A00400000000000000D1 /* PhoneForwardingMode.swift in Sources */, D1F0A00100000000000000A1 /* PhonePushClient.swift in Sources */, D1F0A00100000000000000A3 /* PhonePushPayload.swift in Sources */, + D1F0A00100000000000000A5 /* PhonePushPayloadKind.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001521 /* PostHogAnalytics.swift in Sources */, C47110020000000000000001 /* ProcessPipeReader.swift in Sources */, From 7518ab7822ba823302a5b9291b288832ecaedd80 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:48:01 -0700 Subject: [PATCH 24/31] Satisfy mobile package convention lint --- .../CMUXMobileCore/CompactAttachTicket.swift | 14 +--- .../MobileShellComposite.swift | 74 ++++++++++--------- 2 files changed, 40 insertions(+), 48 deletions(-) diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift index ce313999190..2ea3d5b9ca3 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift @@ -37,8 +37,8 @@ struct CompactAttachTicket: Codable { terminalID: t, macDeviceID: d, macDisplayName: nil, - macUserEmail: legacyCompactEmail(from: u), - macUserID: compactOpaqueUserID(from: u), + macUserEmail: u?.contains("@") == true ? u : nil, + macUserID: u?.contains("@") == false ? u : nil, macPairingCompatibilityVersion: pc, macAppVersion: av, macAppBuild: ab, @@ -56,16 +56,6 @@ struct CompactAttachTicket: Codable { } -private func legacyCompactEmail(from value: String?) -> String? { - guard let value, value.contains("@") else { return nil } - return value -} - -private func compactOpaqueUserID(from value: String?) -> String? { - guard let value, !value.contains("@") else { return nil } - return value -} - private extension CompactAttachTicket { /// Encode routes, omitting each route id the decoder can resynthesize /// (`kind` for the first route of a kind, `kind_N` for the Nth; exactly diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index eae80b959a0..56d03eef6f8 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -3083,15 +3083,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { actualUserID: String?, actualEmail: String? ) -> MobilePairingFailureCategory? { - if let expectedUserID = mobileShellNormalizedNonEmpty(ticket.macUserID) { - guard let actualUserID = mobileShellNormalizedNonEmpty(actualUserID) else { return nil } + if let expectedUserID = Self.mobileShellNormalizedNonEmpty(ticket.macUserID) { + guard let actualUserID = Self.mobileShellNormalizedNonEmpty(actualUserID) else { return nil } guard actualUserID == expectedUserID else { return .authFailed } return nil } - guard let actual = mobileShellNormalizedEmail(actualEmail) else { return nil } - if let expected = mobileShellNormalizedEmail(ticket.macUserEmail) { + guard let actual = Self.mobileShellNormalizedEmail(actualEmail) else { return nil } + if let expected = Self.mobileShellNormalizedEmail(ticket.macUserEmail) { guard actual == expected else { return .emailMismatch(expected: expected, actual: actual) } @@ -3106,20 +3106,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return nil } let phoneStamp = feedbackStampProvider() - let phoneVersion = mobileShellNormalizedNonEmpty(phoneStamp.appVersion) - let macVersion = mobileShellNormalizedNonEmpty(ticket.macAppVersion) + let phoneVersion = Self.mobileShellNormalizedNonEmpty(phoneStamp.appVersion) + let macVersion = Self.mobileShellNormalizedNonEmpty(ticket.macAppVersion) let format = L10n.string( "mobile.pairing.versionWarningFormat", defaultValue: "This iPhone is running cmux %@, but the Mac is running cmux %@. Pairing across different compatibility levels can break terminal input, workspace sync, or notifications. Continue only if you trust this Mac and accept that some features may fail." ) return String( format: format, - mobileShellVersionDisplay( + Self.mobileShellVersionDisplay( version: phoneVersion, build: phoneStamp.appBuild, compatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion ), - mobileShellVersionDisplay( + Self.mobileShellVersionDisplay( version: macVersion, build: ticket.macAppBuild, compatibilityVersion: macCompatibilityVersion @@ -4799,39 +4799,41 @@ private struct MobileManualAttachTicketCreateResponse: Decodable, Sendable { } } -private func mobileShellVersionDisplay( - version: String?, - build: String?, - compatibilityVersion: Int? -) -> String { - let version = version ?? mobileShellCompatibilityDisplay(compatibilityVersion) - guard let build = mobileShellNormalizedNonEmpty(build) else { return version } - return "\(version) (\(build))" -} +private extension MobileShellComposite { + static func mobileShellVersionDisplay( + version: String?, + build: String?, + compatibilityVersion: Int? + ) -> String { + let version = version ?? mobileShellCompatibilityDisplay(compatibilityVersion) + guard let build = mobileShellNormalizedNonEmpty(build) else { return version } + return "\(version) (\(build))" + } -private func mobileShellCompatibilityDisplay(_ compatibilityVersion: Int?) -> String { - guard let compatibilityVersion, compatibilityVersion > 0 else { - return L10n.string( - "mobile.pairing.compatibilityUnknown", - defaultValue: "unknown compatibility" + static func mobileShellCompatibilityDisplay(_ compatibilityVersion: Int?) -> String { + guard let compatibilityVersion, compatibilityVersion > 0 else { + return L10n.string( + "mobile.pairing.compatibilityUnknown", + defaultValue: "unknown compatibility" + ) + } + return String( + format: L10n.string( + "mobile.pairing.compatibilityDisplayFormat", + defaultValue: "compatibility %@" + ), + "\(compatibilityVersion)" ) } - return String( - format: L10n.string( - "mobile.pairing.compatibilityDisplayFormat", - defaultValue: "compatibility %@" - ), - "\(compatibilityVersion)" - ) -} -private func mobileShellNormalizedEmail(_ value: String?) -> String? { - mobileShellNormalizedNonEmpty(value)?.lowercased() -} + static func mobileShellNormalizedEmail(_ value: String?) -> String? { + mobileShellNormalizedNonEmpty(value)?.lowercased() + } -private func mobileShellNormalizedNonEmpty(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed?.isEmpty == false ? trimmed : nil + static func mobileShellNormalizedNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } } private extension CmxAttachTicket { From 6cb16477fed5f93511a68a3c259a484dd10195f7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:57:59 -0700 Subject: [PATCH 25/31] Refresh Swift file length budget for pairing flow --- .github/swift-file-length-budget.tsv | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index f976aee6d1b..6587cdb6207 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -21,7 +21,7 @@ 6071 Sources/TextBoxInput.swift 5482 cmuxTests/BrowserConfigTests.swift 5462 Sources/cmuxApp.swift -4726 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +4894 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift @@ -39,11 +39,11 @@ 2545 cmuxTests/WorkspaceManualUnreadTests.swift 2544 cmuxTests/CommandPaletteSearchEngineTests.swift 2516 Sources/KeyboardShortcutSettings.swift +2340 Sources/Mobile/MobileHostService.swift 2327 cmuxTests/CJKIMEInputTests.swift -2322 Sources/Mobile/MobileHostService.swift 2314 Sources/FileExplorerView.swift -2291 Sources/TerminalNotificationStore.swift 2260 Sources/TerminalWindowPortal.swift +2232 Sources/TerminalNotificationStore.swift 2198 Sources/SessionPersistence.swift 2123 cmuxTests/ShortcutAndCommandPaletteTests.swift 2117 cmuxTests/CmuxConfigTests.swift @@ -68,8 +68,8 @@ 1372 cmuxTests/AppDelegateIssue2907RoutingTests.swift 1365 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift -1312 cmuxTests/MobileHostAuthorizationTests.swift 1285 cmuxUITests/SidebarHelpMenuUITests.swift +1272 cmuxTests/MobileHostAuthorizationTests.swift 1255 Sources/Feed/FeedCoordinator.swift 1205 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1197 cmuxTests/CodexAppServerSessionTests.swift From a74a9fc9ca34777c935c4febe858f28feafcb557 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:54:10 -0700 Subject: [PATCH 26/31] Guard pairing warning acceptance taps --- .../Sources/CmuxMobileShellUI/PairingView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift index 3a0fcd454b8..e3188b3be93 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift @@ -167,6 +167,7 @@ struct PairingView: View { } label: { Text(L10n.string("mobile.pairing.versionWarningContinue", defaultValue: "Continue anyway")) } + .disabled(isPairing) .accessibilityIdentifier("MobilePairingVersionWarningContinueButton") } } From af3c18b03c315eb075182bc24838fc6050445633 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:20:09 -0700 Subject: [PATCH 27/31] Warn on compact pairing codes without compatibility --- .../Sources/CMUXMobileCore/CompactAttachTicket.swift | 2 +- .../CmxAttachTicketInputTests.swift | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift index 2ea3d5b9ca3..d0ae2b0f7cb 100644 --- a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift @@ -39,7 +39,7 @@ struct CompactAttachTicket: Codable { macDisplayName: nil, macUserEmail: u?.contains("@") == true ? u : nil, macUserID: u?.contains("@") == false ? u : nil, - macPairingCompatibilityVersion: pc, + macPairingCompatibilityVersion: pc ?? 0, macAppVersion: av, macAppBuild: ab, routes: Self.expandedRoutes(r), diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift index 3bf4a50dd5a..ecf74ac3f6d 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift @@ -61,6 +61,17 @@ import Testing #expect(decoded.expiresAt == nil) } + @Test func missingCompactCompatibilityDecodesAsUnknown() throws { + let payload = """ + {"v":1,"d":"mac-1","u":"user_mac_123","r":[{"k":"tailscale","e":{"h":"100.64.0.5","p":8443}}]} + """ + let decoded = try CmxAttachTicketInput.decode( + attachURL(payload: Data(payload.utf8)) + ) + + #expect(decoded.macPairingCompatibilityVersion == 0) + } + @Test func decodesLegacyFullKeyPayloadAttachURL() throws { // New-phone-scans-old-QR: the legacy grammar must keep decoding, // including its auth token. From 71426897f37c97fd22b915727ca5d4cf9ebe6353 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:26:07 -0700 Subject: [PATCH 28/31] Warn on legacy pairing codes without compatibility --- .../CmuxMobileRPC/CmxAttachTicketInput.swift | 28 +++++++++++++++++-- .../CmxAttachTicketInputTests.swift | 15 ++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift index d245c798db4..ec470ca7dc3 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift @@ -52,8 +52,9 @@ public struct CmxAttachTicketInput { decoder.dateDecodingStrategy = .iso8601 ticket = try decoder.decode(CmxAttachTicket.self, from: data) } - try ticket.validate() - return ticket + let normalizedTicket = try ticket.withUnknownCompatibilityVersionForPairingURL() + try normalizedTicket.validate() + return normalizedTicket } private static func ticket(from payload: MobileSyncPairingPayload) throws -> CmxAttachTicket { @@ -85,3 +86,26 @@ public struct CmxAttachTicketInput { return Data(base64Encoded: base64) } } + +private extension CmxAttachTicket { + func withUnknownCompatibilityVersionForPairingURL() throws -> CmxAttachTicket { + guard macPairingCompatibilityVersion == nil else { + return self + } + return try CmxAttachTicket( + version: version, + workspaceID: workspaceID, + terminalID: terminalID, + macDeviceID: macDeviceID, + macDisplayName: macDisplayName, + macUserEmail: macUserEmail, + macUserID: macUserID, + macPairingCompatibilityVersion: 0, + macAppVersion: macAppVersion, + macAppBuild: macAppBuild, + routes: routes, + expiresAt: expiresAt, + authToken: authToken + ) + } +} diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift index ecf74ac3f6d..ddee544b8e5 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift @@ -86,6 +86,21 @@ import Testing #expect(decoded.authToken == "legacy-token") } + @Test func missingLegacyCompatibilityDecodesAsUnknown() throws { + let payload = """ + {"version":1,"workspaceID":"","terminalID":null,"macDeviceID":"mac-1",\ + "macDisplayName":null,"macUserID":"user_mac_123",\ + "routes":[{"id":"tailscale","kind":"tailscale",\ + "endpoint":{"type":"host_port","host":"100.64.0.5","port":8443},\ + "priority":0}]} + """ + let decoded = try CmxAttachTicketInput.decode( + attachURL(payload: Data(payload.utf8)) + ) + + #expect(decoded.macPairingCompatibilityVersion == 0) + } + @Test func compactPayloadFailsLoudlyOnPreCompactDecoder() throws { // Old-phone-scans-new-QR: replicate the decode path shipped before // the compact grammar existed (plain Codable + iso8601) and prove it From 3e506ecc19fead38fd1ca02ee104ab3245934715 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:33:32 -0700 Subject: [PATCH 29/31] Surface pairing warnings from host picker --- .../CmuxMobileRPC/CmxAttachTicketInput.swift | 1 + .../CmxAttachTicketInputTests.swift | 14 +++++++++ .../MobileHostPickerView.swift | 29 ++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift index ec470ca7dc3..c431620dd52 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift @@ -68,6 +68,7 @@ public struct CmxAttachTicketInput { terminalID: nil, macDeviceID: payload.macDeviceID, macDisplayName: payload.macDisplayName, + macPairingCompatibilityVersion: 0, routes: [route], expiresAt: payload.expiresAt ) diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift index ddee544b8e5..955a310d300 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift @@ -187,6 +187,20 @@ import Testing } } + @Test func legacyPairURLDecodesCompatibilityAsUnknown() throws { + let payload = try MobileSyncPairingPayload( + macDeviceID: "mac-1", + macDisplayName: "Studio", + host: "100.64.0.5", + port: 8443, + expiresAt: Date(timeIntervalSince1970: 4_000_000_000), + transport: .tailscale + ) + let decoded = try CmxAttachTicketInput.decode(payload.encodedURL().absoluteString) + + #expect(decoded.macPairingCompatibilityVersion == 0) + } + @Test func legacyLoopbackPayloadStillDecodesForDevInjection() throws { // The simulator/dev auto-pair path (CMUX_DOGFOOD_ATTACH_URL) builds a // legacy full-key payload with a loopback route; it must keep working. diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift index 1ebfaffdcbc..cd4de858ff2 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift @@ -61,12 +61,39 @@ struct MobileHostPickerView: View { MobilePairingScannerSheet { code in showingScanner = false Task { - _ = await store.connectPairingURL(code) + let result = await store.connectPairingURLResult(code) + if result != .needsUserApproval { + await store.loadPairedMacs() + dismiss() + } + } + } + } + } + .alert( + L10n.string("mobile.pairing.versionWarningTitle", defaultValue: "Compatibility mismatch"), + isPresented: Binding( + get: { store.pairingVersionWarning != nil }, + set: { _ in } + ) + ) { + Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel"), role: .cancel) { + store.cancelPairing() + } + Button( + L10n.string("mobile.pairing.versionWarningContinue", defaultValue: "Continue anyway"), + role: .destructive + ) { + Task { + let result = await store.acceptPairingVersionWarning() + if result == .connected { await store.loadPairedMacs() dismiss() } } } + } message: { + Text(store.pairingVersionWarning ?? "") } .accessibilityIdentifier("MobileHostPicker") } From 005cedc0337ad89ba2b7d18df2cf2891a44f6e4b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:43:39 -0700 Subject: [PATCH 30/31] Dismiss host picker after warning retry failures --- .../Sources/CmuxMobileShellUI/MobileHostPickerView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift index cd4de858ff2..4535c5d65aa 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift @@ -86,7 +86,7 @@ struct MobileHostPickerView: View { ) { Task { let result = await store.acceptPairingVersionWarning() - if result == .connected { + if result != .needsUserApproval { await store.loadPairedMacs() dismiss() } From 454c29f11f1c35b5a2bc7956ee641d14eeff9524 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:53:00 -0700 Subject: [PATCH 31/31] Restore pairing validation analytics --- .github/swift-file-length-budget.tsv | 2 +- .../CmuxMobileShell/MobileShellComposite.swift | 13 ++++++++++--- cmuxTests/MobileHostAuthorizationTests.swift | 6 +++++- .../Tests/cmuxFeatureTests/cmuxFeatureTests.swift | 6 +++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 42630c6bd09..5d4112efb27 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -21,7 +21,7 @@ 6074 Sources/TextBoxInput.swift 5500 cmuxTests/BrowserConfigTests.swift 5487 Sources/cmuxApp.swift -5106 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index ad1b8985b29..daa82110e79 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2155,9 +2155,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // itself (127.0.0.1) would make the phone dial itself. Name // the actual fix (Tailscale on the Mac) instead of the // generic invalid-code copy. - applyPairingFailure(.loopbackRejected, phase: "validation") + applyPairingValidationFailure(.loopbackRejected) } else { - applyPairingFailure(.invalidCode, phase: "validation") + applyPairingValidationFailure(.invalidCode) } if connectionState != .connected { connectionState = .disconnected @@ -2172,7 +2172,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { actualUserID: identityProvider?.currentUserID, actualEmail: identityProvider?.currentUserEmail ) { - applyPairingFailure(emailFailure, phase: "validation") + applyPairingValidationFailure(emailFailure) if connectionState != .connected { connectionState = .disconnected macConnectionStatus = .unavailable @@ -3269,6 +3269,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { recordPairingFailed(reason: category.analyticsReason, phase: phase) } + private func applyPairingValidationFailure(_ category: MobilePairingFailureCategory) { + if pairingAttemptMethod == nil { + _ = beginPairingValidationAttempt(method: "qr") + } + applyPairingFailure(category, phase: "validation") + } + /// Clear the error and its guidance together (never bare `connectionError /// = nil`) so guidance cannot linger under a cleared headline. private func clearPairingError() { diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index c74cabbe036..4e0d195a87f 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -1103,7 +1103,11 @@ struct MobileHostAuthorizationTests { } private func drainMobileHostMainQueue() async { - await Task.yield() + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } } } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 33599ebb701..b3c2165da88 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -1535,13 +1535,15 @@ final class TerminalOutputCollector { supportedRouteKinds: [.tailscale], transportFactory: ScriptedTransportFactory(responses: responses) ) + let analytics = RecordingAnalytics() let store = CMUXMobileShellStore( runtime: runtime, workspaces: PreviewMobileHost.workspaces, identityProvider: TestIdentityProvider( currentUserIDValue: "phone-user", currentUserEmailValue: "phone@example.com" - ) + ), + analytics: analytics ) store.signIn() @@ -1554,6 +1556,8 @@ final class TerminalOutputCollector { #expect(store.connectionError?.contains("same email") == true) #expect(store.connectionError?.contains("mac@example.com") == false) #expect(store.connectionError?.contains("phone@example.com") == false) + #expect(analytics.eventCount(named: "ios_pairing_started") == 1) + #expect(analytics.eventCount(named: "ios_pairing_failed") == 1) #expect(try await responses.sentRequests().isEmpty) }