From 801e143646e5f83fb0bfd75ae7d0850d2f521f5f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 00:22:41 -0700 Subject: [PATCH 01/17] iOS pairing: surface network / auth / trust as individual check marks The pairing flow collapsed every failure into one opaque "could not connect", so users couldn't tell a network problem from an auth or trust problem (#6084). This adds a discrete network / authentication / trust checklist whose gates resolve individually, each with an in-progress, success, or failure state and an actionable message on failure. - MobilePairingChecklist model (CmuxMobileShellModel): three gates (network/authentication/trust) each holding pending / inProgress / succeeded / failed(message, guidance), plus .connecting and .connected snapshots. - MobilePairingFailureCategory.stage + clearsPriorGates and a pure MobilePairingChecklist.resolving(_:) builder: the single projection from a classified failure to which check mark fails and which earlier gates the failure proves were cleared (an on-the-wire auth/account rejection proves the network gate; a pre-transport or route-refused failure proves nothing). - MobileShellComposite paints the checklist through the existing failure/success choke points (begin/resolve/connected/clear), gated on an in-flight attempt. - PairingView renders the checklist once the user starts a pairing attempt from the screen, so a background reconnect never shows a stale checklist; the failed gate carries the headline + guidance inline (replacing the single error banner for connection attempts). - Localized the new strings (en + ja) in both string catalogs. - Tests: pure stage/clearsPriorGates/resolving mapping, plus composite end-to-end coverage for offline, auth rejection, account mismatch, and success. Fixes #6084 Co-Authored-By: Claude Opus 4.8 --- .../MobilePairingFailure.swift | 80 ++++++++ .../MobileShellComposite.swift | 53 +++++ .../MobilePairingChecklistTests.swift | 168 ++++++++++++++++ .../MobileShellCompositeChecklistTests.swift | 152 ++++++++++++++ .../MobilePairingChecklist.swift | 112 +++++++++++ .../CMUXMobileRootView.swift | 1 + .../MobilePairingStage+Display.swift | 79 ++++++++ .../PairingChecklistView.swift | 72 +++++++ .../CmuxMobileShellUI/PairingView.swift | 41 +++- Resources/Localizable.xcstrings | 187 ++++++++++++++++++ ios/cmux/Resources/Localizable.xcstrings | 187 ++++++++++++++++++ 11 files changed, 1127 insertions(+), 5 deletions(-) create mode 100644 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift create mode 100644 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift create mode 100644 Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift create mode 100644 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingStage+Display.swift create mode 100644 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index 334df5cf937f..4f1c15a63606 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -1,5 +1,6 @@ public import CMUXMobileCore internal import CmuxMobileRPC +internal import CmuxMobileShellModel internal import CmuxMobileSupport internal import CmuxMobileTransport import Foundation @@ -369,3 +370,82 @@ extension MobilePairingFailureCategory { return String(format: L10n.string(key, defaultValue: defaultValue), host, port) } } + +extension MobilePairingFailureCategory { + /// Which of the three pairing gates (network / authentication / trust) this + /// failure belongs to, so the pairing checklist can mark the right check mark + /// red. `nil` only for ``cancelled`` (not a user-visible failure). + /// + /// The grouping is by gate, not by where in the code the failure is detected: + /// - **network** owns everything about establishing a usable transport to the + /// Mac, including the inputs that make that impossible before a packet is + /// sent (an invalid/loopback code, or no route this build can dial). These + /// all mean "this device could not reach a Mac". + /// - **authentication** owns the credential being rejected on the wire + /// (invalid/expired token or attach ticket). + /// - **trust** owns the security relationship: the Mac is a different account + /// (``accountMismatch``) or the route is not trusted to carry the + /// credential (``unsupportedRoute``). + var stage: MobilePairingStage? { + switch self { + case .offline, .hostUnreachable, .listenerNotRunning, .localNetworkBlocked, + .dnsFailed, .handshakeTimedOut, .connectionDropped, .invalidCode, + .loopbackRejected, .noSupportedRoute, .unknown: + return .network + case .authFailed, .ticketExpired: + return .authentication + case .accountMismatch, .unsupportedRoute: + return .trust + case .cancelled: + return nil + } + } + + /// Whether reaching this failure proves every gate before ``stage`` was + /// already cleared. An on-the-wire rejection from the Mac proves the device + /// reached it (network cleared) and, for an account mismatch, that the + /// credential was read (authentication cleared). A failure detected before or + /// during the transport — offline, unreachable, an invalid code, or a route + /// refused client-side as untrusted (``unsupportedRoute``) — proves nothing, + /// so the earlier gates stay ``MobilePairingStageStatus/pending`` (untested) + /// rather than falsely showing a check mark. + var clearsPriorGates: Bool { + switch self { + case .authFailed, .ticketExpired, .accountMismatch: + return true + default: + return false + } + } +} + +extension MobilePairingChecklist { + /// Build the resolved checklist for a failed attempt: the gate the failure + /// belongs to shows the headline + guidance, every gate the failure proves + /// was cleared shows a check mark, and every other gate stays untested. This + /// is the single projection from "why did pairing fail" to "which check marks + /// the user sees", so it is pure and unit-tested without a live connection. + static func resolving(_ category: MobilePairingFailureCategory) -> MobilePairingChecklist { + guard let failedStage = category.stage else { + // `.cancelled` is handled by the `catch is CancellationError` branches + // before classification, so this is only defensive: a cancelled + // attempt resolves nothing. + return MobilePairingChecklist(network: .pending, authentication: .pending, trust: .pending) + } + let failure = MobilePairingStageStatus.failed( + message: category.message, + guidance: category.guidance + ) + let priorCleared = category.clearsPriorGates + func status(for stage: MobilePairingStage) -> MobilePairingStageStatus { + if stage == failedStage { return failure } + if stage.order < failedStage.order { return priorCleared ? .succeeded : .pending } + return .pending + } + return MobilePairingChecklist( + network: status(for: .network), + authentication: status(for: .authentication), + trust: status(for: .trust) + ) + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index ac7222dde19c..c343d0d1f8a4 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -136,6 +136,15 @@ 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? + /// The per-gate status (network / authentication / trust) of the in-flight or + /// most recent pairing attempt, surfaced as individual check marks in + /// ``PairingView`` so the user can see exactly which stage succeeded or failed + /// (https://github.com/manaflow-ai/cmux/issues/6084). `nil` before any + /// attempt. Painted for every instrumented attempt (including background + /// reconnects); ``PairingView`` only renders it once the user starts a + /// foreground pairing attempt, so a background reconnect never shows a stale + /// checklist. + public private(set) var pairingChecklist: MobilePairingChecklist? public private(set) var activeTicket: CmxAttachTicket? public private(set) var activeRoute: CmxAttachRoute? @@ -642,6 +651,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.terminalInputText = "" self.connectionError = nil self.connectionErrorGuidance = nil + self.pairingChecklist = nil self.activeTicket = nil self.activeRoute = nil self.selectedWorkspaceID = workspaces.first?.id @@ -3110,9 +3120,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { "is_first_pair": .bool(pairingAttemptIsFirstPair), "attempt_id": .string(attemptID.uuidString), ]) + // The network gate is now being attempted; start a fresh checklist so + // a superseding attempt never inherits the prior attempt's check marks. + beginPairingChecklist() } else { pairingAttemptStartedAt = nil pairingAttemptMethod = nil + clearPairingChecklist() } return attemptID } @@ -3121,6 +3135,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// the attempt timing so a later state change can't double-fire. private func recordPairingSucceeded() { guard let method = pairingAttemptMethod else { return } + markPairingChecklistConnected() var props: [String: AnalyticsValue] = [ "method": .string(method), "is_first_pair": .bool(pairingAttemptIsFirstPair), @@ -3169,6 +3184,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairingAttemptID = UUID() pairingAttemptStartedAt = nil pairingAttemptMethod = nil + clearPairingChecklist() } /// Apply a classified pairing failure to the user-visible error surface and @@ -3186,6 +3202,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionError = category.message } connectionErrorGuidance = category.guidance + // Resolve before `recordPairingFailed` clears the attempt instrumentation + // (the checklist sink is gated on an in-flight attempt for the same reason + // the analytics emit is). + resolvePairingChecklist(category) recordPairingFailed(reason: category.analyticsReason, phase: phase) } @@ -3196,6 +3216,32 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionErrorGuidance = nil } + /// Start the pairing checklist for an instrumented attempt: the network gate + /// is being attempted, the later gates wait their turn. + private func beginPairingChecklist() { + pairingChecklist = .connecting + } + + /// Project a classified failure onto the per-gate checklist. Gated on an + /// in-flight attempt (``pairingAttemptMethod``) so live-connection auth + /// evictions and operational errors — which reuse the same classifier — never + /// repaint the pairing checklist. + private func resolvePairingChecklist(_ category: MobilePairingFailureCategory) { + guard pairingAttemptMethod != nil else { return } + pairingChecklist = .resolving(category) + } + + /// Mark every gate cleared once an attempt connects. + private func markPairingChecklistConnected() { + pairingChecklist = .connected + } + + /// Drop the checklist on teardown (cancel, sign-out, switch, forget) so the + /// next ``PairingView`` starts clean. + private func clearPairingChecklist() { + pairingChecklist = 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. @@ -3209,6 +3255,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { applyPairingFailure(category ?? .unknown(host: nil, port: nil), phase: phase) return } + // `connect()` already set the headline (e.g. `noSupportedRoute`); keep the + // checklist in step with that message before the instrumentation clears. + resolvePairingChecklist(category ?? .unknown(host: nil, port: nil)) recordPairingFailed(reason: category?.analyticsReason ?? "other", phase: phase) } @@ -4812,6 +4861,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionState = .disconnected macConnectionStatus = .unavailable clearRemoteConnectionContext() + // Same in-flight-attempt gate as the analytics emit below: paints the + // failed gate (auth or trust) for a foreground pairing attempt, no-ops for + // a live-connection auth eviction. + resolvePairingChecklist(category) // Only emits while a pairing attempt is in flight: `recordPairingFailed` // no-ops once `pairingAttemptMethod` is nil (cleared on success and by // `invalidatePairingAttempt`), so live-connection auth failures that diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift new file mode 100644 index 000000000000..abfd4dbe0496 --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift @@ -0,0 +1,168 @@ +import CMUXMobileCore +import CmuxMobileRPC +import CmuxMobileShellModel +import CmuxMobileTransport +import Foundation +import Testing +@testable import CmuxMobileShell + +/// Tests the pure projection from a classified pairing failure to the three +/// network / authentication / trust check marks (issue #6084). Every failure +/// resolves to exactly one failed gate, the gates it provably cleared show a +/// check, and the rest stay untested — verified without a live connection. +@Suite struct MobilePairingChecklistTests { + // MARK: - Stage assignment + + @Test func everyNonCancelledCategoryHasAStage() throws { + let categories: [MobilePairingFailureCategory] = [ + .offline, + .hostUnreachable(host: "h", port: 1), + .listenerNotRunning(host: "h", port: 1), + .localNetworkBlocked, + .dnsFailed(host: "h", port: 1), + .handshakeTimedOut(host: "h", port: 1), + .connectionDropped(host: "h", port: 1), + .accountMismatch, + .authFailed, + .ticketExpired, + .invalidCode, + .loopbackRejected, + .unsupportedRoute, + .noSupportedRoute, + .unknown(host: "h", port: 1), + ] + for category in categories { + #expect(category.stage != nil, "category \(category) must map to a gate") + } + } + + @Test func cancelledHasNoStage() { + #expect(MobilePairingFailureCategory.cancelled.stage == nil) + } + + @Test func reachabilityFailuresAreNetworkStage() { + let networkCategories: [MobilePairingFailureCategory] = [ + .offline, + .hostUnreachable(host: "h", port: 1), + .listenerNotRunning(host: "h", port: 1), + .localNetworkBlocked, + .dnsFailed(host: "h", port: 1), + .handshakeTimedOut(host: "h", port: 1), + .connectionDropped(host: "h", port: 1), + .invalidCode, + .loopbackRejected, + .noSupportedRoute, + .unknown(host: "h", port: 1), + ] + for category in networkCategories { + #expect(category.stage == .network, "\(category) should be a network-gate failure") + } + } + + @Test func credentialFailuresAreAuthenticationStage() { + #expect(MobilePairingFailureCategory.authFailed.stage == .authentication) + #expect(MobilePairingFailureCategory.ticketExpired.stage == .authentication) + } + + @Test func accountAndRouteFailuresAreTrustStage() { + #expect(MobilePairingFailureCategory.accountMismatch.stage == .trust) + #expect(MobilePairingFailureCategory.unsupportedRoute.stage == .trust) + } + + @Test func onlyOnWireAuthFailuresClearPriorGates() { + #expect(MobilePairingFailureCategory.authFailed.clearsPriorGates) + #expect(MobilePairingFailureCategory.ticketExpired.clearsPriorGates) + #expect(MobilePairingFailureCategory.accountMismatch.clearsPriorGates) + // Pre-transport and route-refused failures prove nothing about earlier gates. + #expect(!MobilePairingFailureCategory.offline.clearsPriorGates) + #expect(!MobilePairingFailureCategory.hostUnreachable(host: "h", port: 1).clearsPriorGates) + #expect(!MobilePairingFailureCategory.unsupportedRoute.clearsPriorGates) + #expect(!MobilePairingFailureCategory.invalidCode.clearsPriorGates) + } + + // MARK: - Resolved checklist + + @Test func offlineFailsNetworkAndLeavesLaterGatesUntested() { + let category = MobilePairingFailureCategory.offline + let checklist = MobilePairingChecklist.resolving(category) + #expect(checklist.network == .failed(message: category.message, guidance: category.guidance)) + #expect(checklist.authentication == .pending) + #expect(checklist.trust == .pending) + #expect(checklist.failedStage == .network) + } + + @Test func authFailureClearsNetworkAndLeavesTrustUntested() { + let category = MobilePairingFailureCategory.authFailed + let checklist = MobilePairingChecklist.resolving(category) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication == .failed(message: category.message, guidance: category.guidance)) + #expect(checklist.trust == .pending) + #expect(checklist.failedStage == .authentication) + } + + @Test func ticketExpiredFailsAuthenticationGate() { + let category = MobilePairingFailureCategory.ticketExpired + let checklist = MobilePairingChecklist.resolving(category) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication.isFailed) + #expect(checklist.trust == .pending) + } + + @Test func accountMismatchClearsNetworkAndAuthThenFailsTrust() { + let category = MobilePairingFailureCategory.accountMismatch + let checklist = MobilePairingChecklist.resolving(category) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication == .succeeded) + #expect(checklist.trust == .failed(message: category.message, guidance: category.guidance)) + #expect(checklist.failedStage == .trust) + } + + @Test func untrustedRouteFailsTrustWithoutClaimingEarlierGates() { + // A route refused client-side never reaches the Mac, so the earlier gates + // stay untested even though trust is the failed gate. + let category = MobilePairingFailureCategory.unsupportedRoute + let checklist = MobilePairingChecklist.resolving(category) + #expect(checklist.network == .pending) + #expect(checklist.authentication == .pending) + #expect(checklist.trust == .failed(message: category.message, guidance: category.guidance)) + } + + @Test func invalidCodeFailsNetworkGate() { + let category = MobilePairingFailureCategory.invalidCode + let checklist = MobilePairingChecklist.resolving(category) + #expect(checklist.network.isFailed) + #expect(checklist.authentication == .pending) + #expect(checklist.trust == .pending) + } + + // MARK: - Static snapshots and helpers + + @Test func connectingChecklistAttemptsNetworkFirst() { + let checklist = MobilePairingChecklist.connecting + #expect(checklist.network == .inProgress) + #expect(checklist.authentication == .pending) + #expect(checklist.trust == .pending) + #expect(checklist.isInProgress) + #expect(checklist.failedStage == nil) + } + + @Test func connectedChecklistClearsEveryGate() { + let checklist = MobilePairingChecklist.connected + for stage in MobilePairingStage.allCases { + #expect(checklist.status(for: stage) == .succeeded) + } + #expect(!checklist.isInProgress) + #expect(checklist.failedStage == nil) + } + + @Test func stageAccessorMatchesStoredStatuses() { + let checklist = MobilePairingChecklist( + network: .succeeded, + authentication: .inProgress, + trust: .pending + ) + #expect(checklist.status(for: .network) == .succeeded) + #expect(checklist.status(for: .authentication) == .inProgress) + #expect(checklist.status(for: .trust) == .pending) + } +} diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift new file mode 100644 index 000000000000..896e32b6a166 --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -0,0 +1,152 @@ +import CMUXMobileCore +import CmuxMobileRPC +import CmuxMobileShellModel +import CmuxMobileTransport +import Foundation +import Testing +@testable import CmuxMobileShell + +/// End-to-end coverage that a real pairing attempt drives the network / +/// authentication / trust checklist to the right per-gate state (issue #6084): +/// the offline preflight, an on-the-wire auth rejection, an account mismatch, and +/// a clean success each resolve a distinct shape. Reuses the scripted-host +/// harness from `MobileShellRenderGridLivenessTestSupport.swift`. +@Suite @MainActor struct MobileShellCompositeChecklistTests { + @Test func offlinePreflightFailsOnlyTheNetworkGate() async throws { + let store = MobileShellComposite(reachability: StubReachability(online: false)) + store.signIn() + // A non-loopback host triggers the reachability preflight (loopback routes + // skip it), so the attempt short-circuits before any transport work. + await store.connectManualHost(name: "Work Mac", host: "100.64.0.1", port: 58_465) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network.isFailed) + #expect(checklist.authentication == .pending) + #expect(checklist.trust == .pending) + } + + @Test func authRejectionClearsNetworkThenFailsAuthenticationGate() async throws { + let store = makeStore(errorCode: "unauthorized", message: "invalid token") + let connected = await store.connectPairingURL(try attachURL(for: makeTicket(clock: TestClock()))) + #expect(!connected) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication.isFailed) + #expect(checklist.trust == .pending) + } + + @Test func accountMismatchClearsNetworkAndAuthThenFailsTrustGate() async throws { + let store = makeStore(errorCode: "account_mismatch", message: "different account") + let connected = await store.connectPairingURL(try attachURL(for: makeTicket(clock: TestClock()))) + #expect(!connected) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication == .succeeded) + #expect(checklist.trust.isFailed) + } + + @Test func successfulPairingClearsEveryGate() async throws { + let store = try await makeConnectedStore( + router: LivenessHostRouter(), + box: TransportBox(), + clock: TestClock() + ) + #expect(store.pairingChecklist == .connected) + } + + // MARK: - Harness + + private func makeStore(errorCode: String?, message: String) -> MobileShellComposite { + let runtime = LivenessTestRuntime( + transportFactory: ChecklistErrorTransportFactory(code: errorCode, message: message), + now: { TestClock().now }, + pairingRequestTimeoutNanoseconds: 5_000_000_000 + ) + let store = MobileShellComposite.preview(runtime: runtime) + store.signIn() + return store + } +} + +/// Reports a fixed online/offline verdict and never emits a path change, for the +/// reachability preflight test. +struct StubReachability: ReachabilityProviding { + let online: Bool + var isOnline: Bool { get async { online } } + func pathChanges() -> AsyncStream { + AsyncStream { $0.finish() } + } +} + +/// A transport that answers every framed request with one configured RPC error +/// frame, so a pairing attempt fails at the authentication/trust gate without a +/// real host. Mirrors the receive/deliver pump of `LivenessTransport`. +actor ChecklistErrorTransport: CmxByteTransport { + private let code: String? + private let message: String + private var pendingFrames: [Data] = [] + private var receiveWaiters: [CheckedContinuation] = [] + private var isClosed = false + + init(code: String?, message: String) { + self.code = code + self.message = message + } + + func connect() async throws {} + + func receive() async throws -> Data? { + if !pendingFrames.isEmpty { + return pendingFrames.removeFirst() + } + if isClosed { + return nil + } + return await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func send(_ data: Data) async throws { + var buffer = data + let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) + for payload in payloads { + let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] + guard let id = parsed?["id"] as? String else { continue } + var error: [String: Any] = ["message": message] + if let code { + error["code"] = code + } + let envelope: [String: Any] = ["id": id, "ok": false, "error": error] + guard let frame = try? MobileSyncFrameCodec.encodeFrame( + JSONSerialization.data(withJSONObject: envelope) + ) else { continue } + deliver(frame) + } + } + + func close() async { + isClosed = true + let waiters = receiveWaiters + receiveWaiters = [] + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + private func deliver(_ frame: Data) { + if receiveWaiters.isEmpty { + pendingFrames.append(frame) + return + } + receiveWaiters.removeFirst().resume(returning: frame) + } +} + +struct ChecklistErrorTransportFactory: CmxByteTransportFactory { + let code: String? + let message: String + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + ChecklistErrorTransport(code: code, message: message) + } +} diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift new file mode 100644 index 000000000000..8fc8aef27075 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift @@ -0,0 +1,112 @@ +import Foundation + +/// One of the three discrete gates a pairing attempt must clear, in the order +/// they are attempted. Surfacing each as its own check mark lets the user tell +/// exactly which stage succeeded or failed instead of reading one opaque +/// "could not connect" (https://github.com/manaflow-ai/cmux/issues/6084). +public enum MobilePairingStage: Equatable, Sendable, CaseIterable { + /// Reaching the Mac over the network: reachability, routing, the listener, + /// and opening the transport to the address the pairing code points at. The + /// first gate — nothing else can be attempted until it clears. + case network + /// Verifying this device's signed-in account credential with the Mac. + case authentication + /// Confirming the Mac belongs to the same cmux account, over a route trusted + /// to carry that credential. The last gate. + case trust + + /// Position in the attempt order, used to decide which gates an earlier + /// failure leaves untested (`.pending`) versus provably cleared. + public var order: Int { + switch self { + case .network: return 0 + case .authentication: return 1 + case .trust: return 2 + } + } +} + +/// The resolution state of a single pairing gate, mirrored into an individual +/// check mark in the pairing UI. +public enum MobilePairingStageStatus: Equatable, Sendable { + /// Not started, or left untested because an earlier gate has not cleared. + case pending + /// Currently being attempted. + case inProgress + /// Cleared. + case succeeded + /// Failed, carrying the localized headline and optional actionable guidance + /// the UI shows beneath this gate's row. + case failed(message: String, guidance: String?) + + /// Whether this gate is the one that failed. + public var isFailed: Bool { + if case .failed = self { return true } + return false + } + + /// The failure headline, when this gate failed. + public var failureMessage: String? { + if case let .failed(message, _) = self { return message } + return nil + } + + /// The actionable next-step line, when this gate failed and one applies. + public var failureGuidance: String? { + if case let .failed(_, guidance) = self { return guidance } + return nil + } +} + +/// The per-gate status of the network / authentication / trust pairing +/// checklist. A value type so the whole "how far did pairing get" projection is +/// computed in one place and rendered as plain immutable data by the UI. +public struct MobilePairingChecklist: Equatable, Sendable { + public var network: MobilePairingStageStatus + public var authentication: MobilePairingStageStatus + public var trust: MobilePairingStageStatus + + public init( + network: MobilePairingStageStatus, + authentication: MobilePairingStageStatus, + trust: MobilePairingStageStatus + ) { + self.network = network + self.authentication = authentication + self.trust = trust + } + + /// The status of a given gate. + public func status(for stage: MobilePairingStage) -> MobilePairingStageStatus { + switch stage { + case .network: return network + case .authentication: return authentication + case .trust: return trust + } + } + + /// The gate that failed, if any (at most one gate fails per attempt). + public var failedStage: MobilePairingStage? { + MobilePairingStage.allCases.first { status(for: $0).isFailed } + } + + /// True while an attempt is in flight and no gate has resolved yet. + public var isInProgress: Bool { + MobilePairingStage.allCases.contains { status(for: $0) == .inProgress } + } + + /// The checklist while an attempt is in flight: the network gate is being + /// attempted; the later gates wait their turn. + public static let connecting = MobilePairingChecklist( + network: .inProgress, + authentication: .pending, + trust: .pending + ) + + /// The checklist once every gate has cleared. + public static let connected = MobilePairingChecklist( + network: .succeeded, + authentication: .succeeded, + trust: .succeeded + ) +} diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 4fc6cc3a1912..44a80bf015c1 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -191,6 +191,7 @@ struct CMUXMobileRootView: View { pairingCode: $store.pairingCode, connectionError: store.connectionError, connectionErrorGuidance: store.connectionErrorGuidance, + pairingChecklist: store.pairingChecklist, connectPairingCode: { await store.connectPairingInput() }, diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingStage+Display.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingStage+Display.swift new file mode 100644 index 000000000000..564cd6d17de7 --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingStage+Display.swift @@ -0,0 +1,79 @@ +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Display-only derivations for the pairing checklist: the localized title and +/// one-line "what this gate checks" detail for each gate, and the icon / tint / +/// accessibility wording for each gate status. Kept out of the model so the +/// model stays UI-agnostic and these strings live next to the view that shows +/// them (https://github.com/manaflow-ai/cmux/issues/6084). +extension MobilePairingStage { + var title: String { + switch self { + case .network: + return L10n.string("mobile.pairing.checklist.network.title", defaultValue: "Network") + case .authentication: + return L10n.string("mobile.pairing.checklist.authentication.title", defaultValue: "Authentication") + case .trust: + return L10n.string("mobile.pairing.checklist.trust.title", defaultValue: "Trust") + } + } + + /// The neutral one-line description shown while the gate is pending, in + /// progress, or cleared (a failure replaces it with the actionable message). + var detail: String { + switch self { + case .network: + return L10n.string("mobile.pairing.checklist.network.detail", defaultValue: "Reaching your Mac") + case .authentication: + return L10n.string("mobile.pairing.checklist.authentication.detail", defaultValue: "Verifying your account") + case .trust: + return L10n.string("mobile.pairing.checklist.trust.detail", defaultValue: "Confirming it's your Mac") + } + } + + /// Stable suffix for the row's accessibility identifier, so UI tests can + /// target a specific gate. + var accessibilityIdentifierSuffix: String { + switch self { + case .network: return "network" + case .authentication: return "authentication" + case .trust: return "trust" + } + } +} + +extension MobilePairingStageStatus { + var symbolName: String { + switch self { + case .pending: return "circle" + case .inProgress: return "circle.dotted" + case .succeeded: return "checkmark.circle.fill" + case .failed: return "xmark.circle.fill" + } + } + + var tintColor: Color { + switch self { + case .pending: return .secondary + case .inProgress: return .blue + case .succeeded: return .green + case .failed: return .red + } + } + + /// A localized status word appended to the gate's VoiceOver label, so the + /// status is announced even though it is conveyed visually by icon + color. + var accessibilityValue: String { + switch self { + case .pending: + return L10n.string("mobile.pairing.checklist.status.pending", defaultValue: "Not started") + case .inProgress: + return L10n.string("mobile.pairing.checklist.status.inProgress", defaultValue: "In progress") + case .succeeded: + return L10n.string("mobile.pairing.checklist.status.succeeded", defaultValue: "Succeeded") + case .failed: + return L10n.string("mobile.pairing.checklist.status.failed", defaultValue: "Failed") + } + } +} diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift new file mode 100644 index 000000000000..e10b8896408a --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift @@ -0,0 +1,72 @@ +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// The network / authentication / trust pairing checklist: one resolving check +/// mark per gate so the user can see exactly which stage of pairing succeeded or +/// failed, instead of one opaque "could not connect" +/// (https://github.com/manaflow-ai/cmux/issues/6084). +/// +/// A pure value view — it takes the immutable ``MobilePairingChecklist`` snapshot +/// and renders it, holding no store reference, so it is safe to embed in the +/// pairing form. +struct PairingChecklistRows: View { + let checklist: MobilePairingChecklist + + var body: some View { + ForEach(MobilePairingStage.allCases, id: \.self) { stage in + PairingChecklistRow(stage: stage, status: checklist.status(for: stage)) + } + } +} + +private struct PairingChecklistRow: View { + let stage: MobilePairingStage + let status: MobilePairingStageStatus + + var body: some View { + HStack(alignment: .top, spacing: 12) { + statusIcon + .frame(width: 28, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text(stage.title) + .font(.body) + .foregroundStyle(.primary) + + if let message = status.failureMessage { + Text(message) + .font(.footnote) + .foregroundStyle(.primary) + if let guidance = status.failureGuidance { + Text(guidance) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + Text(stage.detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("MobilePairingChecklistRow.\(stage.accessibilityIdentifierSuffix)") + .accessibilityValue(status.accessibilityValue) + } + + @ViewBuilder + private var statusIcon: some View { + switch status { + case .inProgress: + ProgressView() + .controlSize(.small) + default: + Image(systemName: status.symbolName) + .font(.title3) + .foregroundStyle(status.tintColor) + } + } +} diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift index 602085f78a30..4dc8ff43e0b8 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift @@ -18,6 +18,11 @@ 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? + /// Per-gate (network / authentication / trust) status of the current pairing + /// attempt, shown as individual check marks. Rendered only after the user + /// starts a pairing attempt from this screen (see ``hasStartedPairing``), so a + /// background reconnect never surfaces a stale checklist here. + let pairingChecklist: MobilePairingChecklist? let connectPairingCode: () async -> Void let connectManualHost: (String, String, Int) async -> Void let cancelPairing: () -> Void @@ -33,6 +38,10 @@ struct PairingView: View { @Environment(\.tailscaleStatusMonitor) private var tailscaleStatusMonitor @State private var validationError: String? @State private var isPairing = false + /// Set the first time the user starts a pairing attempt from this screen, so + /// the connection checklist appears only for an attempt the user initiated + /// here — never for a stale background reconnect's result. + @State private var hasStartedPairing = false @State private var pairingTaskID: UUID? @State private var pairingTask: Task? @FocusState private var focusedField: AddDeviceField? @@ -142,6 +151,15 @@ struct PairingView: View { } } + if showsChecklist, let pairingChecklist { + Section { + PairingChecklistRows(checklist: pairingChecklist) + } header: { + Text(L10n.string("mobile.pairing.checklist.title", defaultValue: "Connection status")) + } + .accessibilityIdentifier("MobilePairingChecklist") + } + if let errorText { Section { VStack(alignment: .leading, spacing: 8) { @@ -233,15 +251,27 @@ struct PairingView: View { } } + /// Whether to show the network / authentication / trust checklist. Only for + /// an attempt the user started on this screen, and never alongside a local + /// form-validation error (which supersedes a prior attempt's result). + private var showsChecklist: Bool { + validationError == nil && hasStartedPairing && pairingChecklist != nil + } + private var errorText: String? { - validationError ?? connectionError + if let validationError { return validationError } + // When the checklist is showing it carries the connection failure inline + // on the gate that failed, so don't also surface it as a separate banner. + if showsChecklist { return nil } + return connectionError } - /// The guidance line only belongs to a connection error. A local validation - /// error (bad host/port) is self-explanatory and has no store-side guidance, - /// so suppress the connection guidance while a validation error is showing. + /// The guidance line only belongs to a surfaced connection-error banner. A + /// local validation error (bad host/port) is self-explanatory, and when the + /// checklist is showing it carries the guidance on the failed gate, so + /// suppress the banner guidance in both cases. private var errorGuidanceText: String? { - guard validationError == nil else { return nil } + guard validationError == nil, !showsChecklist else { return nil } return connectionErrorGuidance } @@ -313,6 +343,7 @@ struct PairingView: View { let taskID = UUID() pairingTaskID = taskID isPairing = true + hasStartedPairing = true let task = Task { @MainActor in defer { if pairingTaskID == taskID { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 87ec6d72f219..dcf64cf473e1 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -198344,6 +198344,193 @@ } } } + }, + "mobile.pairing.checklist.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続状況" + } + } + } + }, + "mobile.pairing.checklist.network.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + } + } + }, + "mobile.pairing.checklist.network.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reaching your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac への接続" + } + } + } + }, + "mobile.pairing.checklist.authentication.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証" + } + } + } + }, + "mobile.pairing.checklist.authentication.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verifying your account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントの確認" + } + } + } + }, + "mobile.pairing.checklist.trust.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trust" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "信頼" + } + } + } + }, + "mobile.pairing.checklist.trust.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirming it's your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あなたの Mac か確認" + } + } + } + }, + "mobile.pairing.checklist.status.pending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not started" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未開始" + } + } + } + }, + "mobile.pairing.checklist.status.inProgress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In progress" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中" + } + } + } + }, + "mobile.pairing.checklist.status.succeeded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Succeeded" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + } + } + }, + "mobile.pairing.checklist.status.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + } + } } } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 3cc98251767b..a65bf39570f9 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -6103,6 +6103,193 @@ } } } + }, + "mobile.pairing.checklist.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続状況" + } + } + } + }, + "mobile.pairing.checklist.network.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + } + } + }, + "mobile.pairing.checklist.network.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reaching your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac への接続" + } + } + } + }, + "mobile.pairing.checklist.authentication.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証" + } + } + } + }, + "mobile.pairing.checklist.authentication.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verifying your account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントの確認" + } + } + } + }, + "mobile.pairing.checklist.trust.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trust" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "信頼" + } + } + } + }, + "mobile.pairing.checklist.trust.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirming it's your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あなたの Mac か確認" + } + } + } + }, + "mobile.pairing.checklist.status.pending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not started" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未開始" + } + } + } + }, + "mobile.pairing.checklist.status.inProgress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In progress" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中" + } + } + } + }, + "mobile.pairing.checklist.status.succeeded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Succeeded" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + } + } + }, + "mobile.pairing.checklist.status.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + } + } } }, "version": "1.0" From d2fea2c5583c061aa54cccce8bf455ab52053b45 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 01:33:27 -0700 Subject: [PATCH 02/17] Pairing checklist: clear the network gate only when the Mac was truly reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autoreview caught a false-success: `reachedMac` was inferred from the coarse pairing phase string, but `MobileCoreRPCClient` can throw `.authorizationFailed` /`.attachTicketExpired` locally (Stack token provider fails, or an attach token is expired) before any packet leaves the device. Those categories clear prior gates, so the network check mark went green for a purely local auth/session failure — actively misleading. Replace the phase guess with a real signal: `MobileCoreRPCSession` records `didAttemptSend` once a request reaches the transport-send stage (after its auth is built), exposed via `MobileCoreRPCClient.didAttemptHostSend()`. `connect()` records whether any client reached that stage into `pairingAttemptReachedMac` (reset per attempt in the shared `beginPairingValidationAttempt` funnel), and the checklist resolver uses it instead of the phase. A host-returned rejection still clears the network gate; a pre-send local failure leaves it untested. Adds a regression test (`preSendTokenFailureLeavesNetworkGateUntested`) for the exact scenario. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../CmuxMobileRPC/MobileCoreRPCClient.swift | 9 +++++ .../CmuxMobileRPC/MobileCoreRPCSession.swift | 10 ++++++ .../MobilePairingFailure.swift | 9 ++--- .../MobileShellComposite.swift | 34 ++++++++++++++----- .../MobileShellCompositeChecklistTests.swift | 21 ++++++++++++ 6 files changed, 71 insertions(+), 14 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 201193c2d3e3..b03485f91faf 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -21,7 +21,7 @@ 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift 5487 Sources/cmuxApp.swift -5169 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5185 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 20533bb0c13a..73fcbe53df76 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -100,6 +100,15 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { } } + /// Whether any request on this client reached the transport-send stage. False + /// means every attempt failed locally before a packet could leave the device + /// (e.g. the Stack token provider failed, or an attach ticket was expired), + /// which pairing uses to avoid marking the network gate cleared for a failure + /// that never reached the Mac (issue #6084). + public func didAttemptHostSend() async -> Bool { + await session.didAttemptSend + } + /// Force a single Stack token refresh ahead of a retry. /// /// The force-refresher closure maps a transient refresh failure (session diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift index 393fb749c280..f8556e0cb9c6 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift @@ -37,6 +37,11 @@ actor MobileCoreRPCSession { private var cancelledQueuedRequestIDs: Set = [] private var listeners: [UUID: EventListener] = [:] private var isTearingDown: Bool = false + /// Whether at least one request has reached the transport-send stage (its auth + /// was built and `send` was entered). Stays false when a request fails locally + /// before any send, letting pairing tell a pre-send auth/token failure apart + /// from a host rejection that proves the network was reached. + private(set) var didAttemptSend = false /// Pending writes drained by `writerTask`. Serializes `transport.send` so /// two concurrent `send(payload:requestID:)` callers never trip /// `CmxNetworkByteTransport.sendAlreadyInProgress`. AsyncStream backed so @@ -56,6 +61,11 @@ actor MobileCoreRPCSession { } func send(payload: Data, requestID: String) async throws -> Data { + // Reaching `send` means the caller already built the request's auth, so any + // failure from here on is a real transport/host interaction (not a local + // pre-send auth/token failure). Pairing reads this to decide whether the + // network gate was genuinely exercised (issue #6084). + didAttemptSend = true _ = try await ensureConnected() let frame = try MobileSyncFrameCodec.encodeFrame(payload) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index 7ef7366f0c9f..75efc6751b2e 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -454,11 +454,12 @@ extension MobilePairingChecklist { /// /// - Parameters: /// - category: The classified failure. - /// - reachedMac: Whether the attempt got on the wire to the Mac (a - /// connect/auth-phase failure), so a gate before the failed one that + /// - reachedMac: Whether the attempt actually got a request onto the + /// transport to the Mac, so a gate before the failed one that /// ``MobilePairingFailureCategory/clearsPriorGates`` marks cleared really - /// was. A pre-network failure (validation or offline preflight) passes - /// `false`, leaving the earlier gates untested instead of falsely cleared. + /// was. A failure that never reached the transport — offline, a bad code, + /// or a local pre-send token/ticket failure — passes `false`, leaving the + /// earlier gates untested instead of falsely cleared. static func resolving( _ category: MobilePairingFailureCategory, reachedMac: Bool diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 696ec0b2ebb1..32c3b8c3531c 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -463,6 +463,12 @@ 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 + /// Whether the in-flight attempt got a request onto the transport (proof it + /// reached the Mac), so the pairing checklist only marks the network gate + /// cleared for an auth/trust failure that the host actually returned — never + /// for a local pre-send token/ticket failure (issue #6084). Reset at each + /// attempt entry; set once `connect()` observes a client that attempted a send. + private var pairingAttemptReachedMac = false private var pendingPairingVersionWarningURL: String? /// The structured diagnostic log, injected from the app composition root. @@ -2966,6 +2972,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return nil } catch { lastError = error + // Record whether this attempt got a request onto the transport, + // so the checklist can tell a host rejection (network reached) + // from a local pre-send token/ticket failure (issue #6084). + if await client.didAttemptHostSend() { + pairingAttemptReachedMac = true + } 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)" @@ -3185,6 +3197,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private func beginPairingValidationAttempt(method: String? = nil) -> UUID { let attemptID = UUID() pairingAttemptID = attemptID + // A fresh attempt has not reached the Mac yet; cleared here (the shared + // funnel for every pairing/validation attempt) so a prior attempt's + // "reached" state can't leak into this one's checklist. + pairingAttemptReachedMac = false if let method { pairingAttemptStartedAt = runtime?.now() ?? Date() pairingAttemptMethod = method @@ -3285,7 +3301,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // Resolve before `recordPairingFailed` clears the attempt instrumentation // (the checklist sink is gated on an in-flight attempt for the same reason // the analytics emit is). - resolvePairingChecklist(category, phase: phase) + resolvePairingChecklist(category) recordPairingFailed(reason: category.analyticsReason, phase: phase) } @@ -3312,13 +3328,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// Project a classified failure onto the per-gate checklist. Gated on an /// in-flight attempt (``pairingAttemptMethod``) so live-connection auth /// evictions and operational errors — which reuse the same classifier — never - /// repaint the pairing checklist. Only a connect/auth-phase failure reached the - /// Mac; a `validation` or `preflight` failure did not, so the earlier gates - /// stay untested rather than falsely cleared. - private func resolvePairingChecklist(_ category: MobilePairingFailureCategory, phase: String) { + /// repaint the pairing checklist. Uses ``pairingAttemptReachedMac`` (set only + /// once a request actually reached the transport) rather than the coarse phase + /// label, so a pre-send token/ticket failure never shows a cleared network gate + /// even though it surfaces in the connect/auth phase. + private func resolvePairingChecklist(_ category: MobilePairingFailureCategory) { guard pairingAttemptMethod != nil else { return } - let reachedMac = phase == "connect" || phase == "auth" - pairingChecklist = .resolving(category, reachedMac: reachedMac) + pairingChecklist = .resolving(category, reachedMac: pairingAttemptReachedMac) } /// Mark every gate cleared once an attempt connects. @@ -3401,7 +3417,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } // `connect()` already set the headline (e.g. `noSupportedRoute`); keep the // checklist in step with that message before the instrumentation clears. - resolvePairingChecklist(category ?? .unknown(host: nil, port: nil), phase: phase) + resolvePairingChecklist(category ?? .unknown(host: nil, port: nil)) recordPairingFailed(reason: category?.analyticsReason ?? "other", phase: phase) } @@ -5000,7 +5016,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // Same in-flight-attempt gate as the analytics emit below: paints the // failed gate (auth or trust) for a foreground pairing attempt, no-ops for // a live-connection auth eviction. - resolvePairingChecklist(category, phase: "auth") + resolvePairingChecklist(category) // Only emits while a pairing attempt is in flight: `recordPairingFailed` // no-ops once `pairingAttemptMethod` is nil (cleared on success and by // `invalidatePairingAttempt`), so live-connection auth failures that diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift index ecd4316fce80..819b736d1629 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -44,6 +44,27 @@ import Testing #expect(checklist.trust.isFailed) } + @Test func preSendTokenFailureLeavesNetworkGateUntested() async throws { + // The Stack token provider fails, so the request never reaches the + // transport. The auth gate fails, but the network gate must stay untested + // (not falsely cleared) since no packet left the device (issue #6084). + struct TokenError: Error {} + let runtime = LivenessTestRuntime( + transportFactory: LivenessTransportFactory(router: LivenessHostRouter(), box: TransportBox()), + stackAccessTokenProvider: { throw TokenError() }, + stackAccessTokenForceRefresher: { throw TokenError() }, + now: { TestClock().now } + ) + let store = MobileShellComposite.preview(runtime: runtime) + store.signIn() + let result = await connectAcceptingVersionWarning(store, try attachURL(for: makeTicket(clock: TestClock()))) + #expect(result == .failed) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .pending) + #expect(checklist.authentication.isFailed) + #expect(checklist.trust == .pending) + } + @Test func successfulPairingClearsEveryGate() async throws { let runtime = LivenessTestRuntime( transportFactory: LivenessTransportFactory(router: LivenessHostRouter(), box: TransportBox()), From 03f836aa09cf0417fb3d03d92a40d65b57a78940 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 01:47:24 -0700 Subject: [PATCH 03/17] Pairing checklist: only count the Mac reached after the transport connects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autoreview follow-up: `didAttemptSend` was flipped before `ensureConnected()`, so a route that failed to connect still reported a host send. With the per-attempt sticky `pairingAttemptReachedMac`, a multi-route ticket whose first route was unreachable could then green the network gate for a later local pre-send auth failure — the exact false positive the feature avoids. Move the flag to after `ensureConnected()` succeeds, so it reflects a genuinely connected channel. Add regression coverage: - CmuxMobileRPC: a connect-failing transport leaves `didAttemptHostSend()` false. - CmuxMobileShell: an unreachable first route followed by a pre-send token failure leaves the network gate untested (`.pending`), not cleared. Co-Authored-By: Claude Opus 4.8 --- .../CmuxMobileRPC/MobileCoreRPCClient.swift | 11 +-- .../CmuxMobileRPC/MobileCoreRPCSession.swift | 21 ++--- .../MobileCoreRPCClientTests.swift | 29 +++++++ .../TransportTestDoubles.swift | 18 +++++ .../MobileShellCompositeChecklistTests.swift | 77 +++++++++++++++++++ 5 files changed, 142 insertions(+), 14 deletions(-) diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 73fcbe53df76..872531fbc80f 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -100,11 +100,12 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { } } - /// Whether any request on this client reached the transport-send stage. False - /// means every attempt failed locally before a packet could leave the device - /// (e.g. the Stack token provider failed, or an attach ticket was expired), - /// which pairing uses to avoid marking the network gate cleared for a failure - /// that never reached the Mac (issue #6084). + /// Whether any request on this client reached the transport over a connected + /// channel. False means every attempt failed locally before a packet could + /// leave the device (the Stack token provider failed, or an attach ticket was + /// expired) or the transport never connected, which pairing uses to avoid + /// marking the network gate cleared for a failure that never reached the Mac + /// (issue #6084). public func didAttemptHostSend() async -> Bool { await session.didAttemptSend } diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift index f8556e0cb9c6..879ed94fe841 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift @@ -37,10 +37,11 @@ actor MobileCoreRPCSession { private var cancelledQueuedRequestIDs: Set = [] private var listeners: [UUID: EventListener] = [:] private var isTearingDown: Bool = false - /// Whether at least one request has reached the transport-send stage (its auth - /// was built and `send` was entered). Stays false when a request fails locally - /// before any send, letting pairing tell a pre-send auth/token failure apart - /// from a host rejection that proves the network was reached. + /// Whether at least one request reached the transport over a *connected* + /// channel (its auth was built and `ensureConnected()` succeeded). Stays false + /// when a request fails locally before any send, or when the transport never + /// connected, letting pairing tell a pre-send/unreachable failure apart from a + /// host rejection that proves the network was reached. private(set) var didAttemptSend = false /// Pending writes drained by `writerTask`. Serializes `transport.send` so /// two concurrent `send(payload:requestID:)` callers never trip @@ -61,12 +62,14 @@ actor MobileCoreRPCSession { } func send(payload: Data, requestID: String) async throws -> Data { - // Reaching `send` means the caller already built the request's auth, so any - // failure from here on is a real transport/host interaction (not a local - // pre-send auth/token failure). Pairing reads this to decide whether the - // network gate was genuinely exercised (issue #6084). - didAttemptSend = true _ = try await ensureConnected() + // Only now is the transport connected: the network path to the Mac is up, + // so any failure from here is a real host interaction — not a local + // pre-send auth/token failure, and not a route that failed to connect. + // Pairing reads this to decide whether the network gate was genuinely + // reached (issue #6084); setting it before `ensureConnected()` would mark + // an unreachable route as reached. + didAttemptSend = true let frame = try MobileSyncFrameCodec.encodeFrame(payload) let result: Result = await withTaskCancellationHandler { diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift index 6d352e9aa6b6..88d4d6fa6344 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift @@ -126,6 +126,35 @@ import Testing _ = try? await firstTask.value } + @Test func didAttemptHostSendStaysFalseWhenTransportNeverConnects() async throws { + // A route that fails to connect must not report a host send: pairing relies + // on this so an unreachable route never marks the network gate as reached + // (issue #6084). + let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 59222) + let runtime = TestMobileSyncRuntime(transportFactory: ConnectFailingTransportFactory()) + let ticket = try CmxAttachTicket( + workspaceID: "ws", + terminalID: "t", + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60), + authToken: "ticket-secret" + ) + let client = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + let request = try MobileCoreRPCClient.requestData(method: "workspace.list", id: "list") + await #expect(throws: (any Error).self) { + _ = try await client.sendRequest(request) + } + let reached = await client.didAttemptHostSend() + #expect(!reached, "a transport that never connects must not report a host send") + } + @Test func workspaceListResponseDecodesSnakeCaseWireShape() throws { let json = Data(""" { diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift index 778406808ea0..6846224cd00f 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift @@ -43,6 +43,24 @@ struct TestMobileSyncRuntime: MobileSyncRuntime { struct MissingTestStackAccessToken: Error {} +/// A transport whose `connect()` always fails, modeling an unreachable route. +/// Used to prove the session never reports a host send when the channel never +/// came up (issue #6084). +actor ConnectFailingTransport: CmxByteTransport { + struct ConnectFailed: Error {} + + func connect() async throws { throw ConnectFailed() } + func receive() async throws -> Data? { nil } + func send(_ data: Data) async throws {} + func close() async {} +} + +struct ConnectFailingTransportFactory: CmxByteTransportFactory { + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + ConnectFailingTransport() + } +} + /// Async-safe one-shot boolean flag used to observe task progress in tests. actor AsyncFlag { private var value = false diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift index 819b736d1629..d13b7b88bd1c 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -65,6 +65,28 @@ import Testing #expect(checklist.trust == .pending) } + @Test func unreachableRouteThenPreSendAuthFailureLeavesNetworkUntested() async throws { + // Multi-route attempt: the first route fails to connect, then a later + // request fails its Stack-token build before any send. The network gate + // must stay untested — an unreachable route must not leave a sticky + // "reached" that greens the network gate (issue #6084 autoreview follow-up). + let provider = FirstCallSucceedsTokenProvider() + let runtime = LivenessTestRuntime( + transportFactory: ConnectFailingTransportFactory(), + stackAccessTokenProvider: { try provider.next() }, + stackAccessTokenForceRefresher: { throw FirstCallSucceedsTokenProvider.TokenError() }, + now: { TestClock().now } + ) + let store = MobileShellComposite.preview(runtime: runtime) + store.signIn() + let result = await connectAcceptingVersionWarning(store, try attachURL(for: makeTwoRouteTicket())) + #expect(result == .failed) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .pending) + #expect(checklist.authentication.isFailed) + #expect(checklist.trust == .pending) + } + @Test func successfulPairingClearsEveryGate() async throws { let runtime = LivenessTestRuntime( transportFactory: LivenessTransportFactory(router: LivenessHostRouter(), box: TransportBox()), @@ -101,6 +123,61 @@ import Testing guard result == .needsUserApproval else { return result } return await store.acceptPairingVersionWarning() } + + private func makeTwoRouteTicket() throws -> CmxAttachTicket { + let routeA = try CmxAttachRoute( + id: "debug_loopback_a", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 51111) + ) + let routeB = try CmxAttachRoute( + id: "debug_loopback_b", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 52222) + ) + return try CmxAttachTicket( + workspaceID: "live-workspace", + terminalID: "live-terminal", + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [routeA, routeB], + expiresAt: TestClock().now.addingTimeInterval(3600) + ) + } +} + +/// A Stack-token provider that succeeds exactly once, then fails — so the first +/// route's request builds auth (and then fails to connect) while a later route's +/// request fails its token build before any send. +final class FirstCallSucceedsTokenProvider: @unchecked Sendable { + struct TokenError: Error {} + private let lock = NSLock() + private var count = 0 + + func next() throws -> String { + let n: Int = lock.withLock { + count += 1 + return count + } + guard n == 1 else { throw TokenError() } + return "token-1" + } +} + +/// A transport whose `connect()` always fails, modeling an unreachable route. +actor ConnectFailingTransport: CmxByteTransport { + struct ConnectFailed: Error {} + + func connect() async throws { throw ConnectFailed() } + func receive() async throws -> Data? { nil } + func send(_ data: Data) async throws {} + func close() async {} +} + +struct ConnectFailingTransportFactory: CmxByteTransportFactory { + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + ConnectFailingTransport() + } } /// Reports a fixed online/offline verdict and never emits a path change, for the From d4d26489c65cf176e8b7d577d63d900af1f19fb1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 01:53:15 -0700 Subject: [PATCH 04/17] Pairing checklist: don't let a superseded attempt poison reached-Mac Autoreview follow-up: `connect()` awaited `client.didAttemptHostSend()` before re-checking the connection generation. On the `@MainActor` store that await can suspend while a newer attempt resets `pairingAttemptReachedMac`; the superseded attempt could then set it back to true, falsely greening the network gate for the new attempt's later local failure. Read the per-client signal into a local, re-check `isCurrentConnectionAttempt` after the await, and only write the shared flag when still current. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 357406cac85f..2e944dbd8ba5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -20,7 +20,7 @@ 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift -5185 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5190 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4921 Sources/cmuxApp.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 32c3b8c3531c..4a8b4531c14f 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2975,10 +2975,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // Record whether this attempt got a request onto the transport, // so the checklist can tell a host rejection (network reached) // from a local pre-send token/ticket failure (issue #6084). - if await client.didAttemptHostSend() { + // Read the per-client signal first, then re-check generation + // before mutating shared state: this `await` can suspend, and a + // newer attempt may have reset `pairingAttemptReachedMac`, so a + // superseded attempt must not write it back. + let didReachHost = await client.didAttemptHostSend() + guard isCurrentConnectionAttempt(generation) else { return nil } + if didReachHost { pairingAttemptReachedMac = true } - 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)" ) From 5aa0e17f64e20151595e54c033e2ac20129e66cf Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 02:12:35 -0700 Subject: [PATCH 05/17] Pairing checklist: only foreground Add Device attempts publish it Autoreview follow-up: the store painted `pairingChecklist` for every instrumented attempt, including background reconnects (`reconnectActiveMacIfAvailable`), host switches (`switchToMac`), and device-tree taps (`connectToRegistryInstance`), which all route through `connectManualHost`. Combined with the sheet's sticky `hasStartedPairing`, a background reconnect while the Add Device sheet stayed open could overwrite and render a checklist for an attempt the user never started. Split `connectManualHost` into the public foreground entry (the Pair button) and an internal `performConnectManualHost(isForegroundPairing:)`; mark QR/link pairing foreground too. Only foreground attempts publish the checklist (begin/resolve/ connected are gated on `isForegroundPairingAttempt`); the non-foreground callers pass `false`. Adds a regression test that a background reconnect leaves `pairingChecklist` nil. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../MobileShellComposite.swift | 46 +++++++++++++++---- .../MobileShellCompositeChecklistTests.swift | 17 +++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 2e944dbd8ba5..dbb8d42699ef 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -20,7 +20,7 @@ 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift -5190 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5218 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4921 Sources/cmuxApp.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 4a8b4531c14f..532443abb1ab 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -469,6 +469,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// for a local pre-send token/ticket failure (issue #6084). Reset at each /// attempt entry; set once `connect()` observes a client that attempted a send. private var pairingAttemptReachedMac = false + /// Whether the in-flight attempt is a user-initiated pairing from the Add + /// Device flow (QR/link scan or the manual Pair button) rather than a + /// background reconnect, host switch, or device-tree tap. Only foreground + /// attempts publish ``pairingChecklist``, so a background reconnect can never + /// overwrite or render the foreground sheet's checklist (issue #6084). + private var isForegroundPairingAttempt = false private var pendingPairingVersionWarningURL: String? /// The structured diagnostic log, injected from the app composition root. @@ -1109,6 +1115,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } public func connectManualHost(name: String, host: String, port: Int) async { + // The public entry is the Add Device "Pair" button — a foreground pairing + // attempt that owns the network/auth/trust checklist. + await performConnectManualHost(name: name, host: host, port: port, isForegroundPairing: true) + } + + /// Manual-host connect shared by the foreground Add Device flow and the + /// non-foreground paths (background reconnect, host switch, device-tree tap). + /// Only foreground attempts publish ``pairingChecklist`` (issue #6084). + func performConnectManualHost( + name: String, + host: String, + port: Int, + isForegroundPairing: Bool + ) async { + isForegroundPairingAttempt = isForegroundPairing let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else { connectionError = L10n.string("mobile.addDevice.invalidHost", defaultValue: "Enter a host or IP address, without spaces or URL paths.") @@ -1265,7 +1286,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.isReconnectingStoredMac = false self.didFinishStoredMacReconnectAttempt = true } - await connectManualHost(name: mac.displayName ?? host, host: host, port: port) + await performConnectManualHost(name: mac.displayName ?? host, host: host, port: port, isForegroundPairing: false) restoringDeadline.cancel() // A newer attempt may have started during the connect; it now owns the flags. guard generation == storedMacReconnectGeneration else { return false } @@ -1743,7 +1764,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // stale/offline. Excluding it would strand the user on a same-device tag // switch failure. let previousActive = pairedMacs.first { $0.isActive } - await connectManualHost(name: device.displayName ?? host, host: host, port: port) + await performConnectManualHost(name: device.displayName ?? host, host: host, port: port, isForegroundPairing: false) // Persist as the active paired Mac only when the live connection is to // THIS route (a switch tapped while this connect was in flight could win // the connection; matching the live route avoids persisting a stale @@ -1830,7 +1851,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { mobileShellLog.error("switchToMac: no reconnectable route mac=\(macDeviceID, privacy: .public)") return } - await connectManualHost(name: target.displayName ?? host, host: host, port: port) + await performConnectManualHost(name: target.displayName ?? host, host: host, port: port, isForegroundPairing: false) // Persist the active row only if the live connection is to THIS Mac's // route. A different switch tapped while this connect was in flight // supersedes it via `beginPairingAttempt`, leaving `connectionState` @@ -2138,6 +2159,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ rawValue: String? = nil, acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { + // QR/link pairing is a foreground Add Device attempt: it owns the checklist. + isForegroundPairingAttempt = true let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) _ = beginPairingValidationAttempt() connectionAttemptGeneration = UUID() @@ -3325,25 +3348,30 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } /// Start the pairing checklist for an instrumented attempt: the network gate - /// is being attempted, the later gates wait their turn. + /// is being attempted, the later gates wait their turn. No-op for a + /// non-foreground attempt (background reconnect / host switch) so it never + /// overwrites the foreground sheet's checklist. private func beginPairingChecklist() { + guard isForegroundPairingAttempt else { return } pairingChecklist = .connecting } - /// Project a classified failure onto the per-gate checklist. Gated on an - /// in-flight attempt (``pairingAttemptMethod``) so live-connection auth - /// evictions and operational errors — which reuse the same classifier — never + /// Project a classified failure onto the per-gate checklist. Gated on a + /// foreground in-flight attempt (``isForegroundPairingAttempt`` + + /// ``pairingAttemptMethod``) so background reconnects, live-connection auth + /// evictions, and operational errors — which reuse the same classifier — never /// repaint the pairing checklist. Uses ``pairingAttemptReachedMac`` (set only /// once a request actually reached the transport) rather than the coarse phase /// label, so a pre-send token/ticket failure never shows a cleared network gate /// even though it surfaces in the connect/auth phase. private func resolvePairingChecklist(_ category: MobilePairingFailureCategory) { - guard pairingAttemptMethod != nil else { return } + guard isForegroundPairingAttempt, pairingAttemptMethod != nil else { return } pairingChecklist = .resolving(category, reachedMac: pairingAttemptReachedMac) } - /// Mark every gate cleared once an attempt connects. + /// Mark every gate cleared once a foreground attempt connects. private func markPairingChecklistConnected() { + guard isForegroundPairingAttempt else { return } pairingChecklist = .connected } diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift index d13b7b88bd1c..25201dbcee2a 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -24,6 +24,23 @@ import Testing #expect(checklist.trust == .pending) } + @Test func backgroundReconnectDoesNotPublishChecklist() async throws { + // A non-foreground attempt (background reconnect, host switch, device-tree + // tap) must not paint the Add Device checklist, so it can never overwrite or + // render the foreground sheet's state (issue #6084 autoreview follow-up). + let store = MobileShellComposite(reachability: StubReachability(online: false)) + store.signIn() + await store.performConnectManualHost( + name: "Stored Mac", + host: "100.64.0.1", + port: 58_465, + isForegroundPairing: false + ) + #expect(store.pairingChecklist == nil) + // The failure is still recorded normally for the (non-checklist) surfaces. + #expect(store.connectionError != nil) + } + @Test func authRejectionClearsNetworkThenFailsAuthenticationGate() async throws { let store = makeStore(errorCode: "unauthorized", message: "invalid token") let result = await connectAcceptingVersionWarning(store, try attachURL(for: makeTicket(clock: TestClock()))) From dbb114b158af374469047686f7d7866ce9170f96 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 02:21:23 -0700 Subject: [PATCH 06/17] Pairing checklist: count the manual attach-ticket probe as reaching the Mac Autoreview follow-up: the foreground manual-host flow runs a pre-connect `mobile.attach_ticket.create` RPC before `connect(ticket:)`. A host rejection there (unauthorized / account mismatch) was resolved with `reachedMac: false`, so the checklist showed Network/Authentication untested even though the Mac was reached. Sample the probe client's `didAttemptHostSend()` (re-checking the connection generation after the await) and set `pairingAttemptReachedMac` so an auth/trust rejection from that probe clears the earlier gates. Adds a regression test driving the manual flow's attach-ticket probe to an account-mismatch rejection. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../MobileShellComposite.swift | 34 +++++++++++++------ .../MobileShellCompositeChecklistTests.swift | 20 +++++++++++ 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index dbb8d42699ef..266db9fcb07c 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -20,7 +20,7 @@ 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift -5218 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5232 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4921 Sources/cmuxApp.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 532443abb1ab..d284d7a29d0b 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2440,16 +2440,30 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ticket: probeTicket, allowsStackAuthFallback: true ) - let resultData = try await client.sendRequest( - MobileCoreRPCClient.requestData( - method: "mobile.attach_ticket.create", - params: [ - "ttl_seconds": 3600, - "scope": "mac", - ] - ), - timeoutNanoseconds: runtime.pairingRequestTimeoutNanoseconds - ) + let generation = connectionAttemptGeneration + let resultData: Data + do { + resultData = try await client.sendRequest( + MobileCoreRPCClient.requestData( + method: "mobile.attach_ticket.create", + params: [ + "ttl_seconds": 3600, + "scope": "mac", + ] + ), + timeoutNanoseconds: runtime.pairingRequestTimeoutNanoseconds + ) + } catch { + // This pre-connect probe reaches the Mac too. If it connected before + // being rejected, record that the Mac was reached so an auth/trust + // rejection here resolves the checklist with the network gate cleared + // (issue #6084). Re-check the generation after the await so a superseded + // attempt can't write the flag back. + if await client.didAttemptHostSend(), isCurrentConnectionAttempt(generation) { + pairingAttemptReachedMac = true + } + throw error + } let response = try MobileManualAttachTicketCreateResponse.decode(resultData) return try response.ticket.constrainingRoutes(to: [route], fallbackDisplayName: displayName) } diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift index 25201dbcee2a..e459ed47e3fe 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -61,6 +61,26 @@ import Testing #expect(checklist.trust.isFailed) } + @Test func manualAttachTicketAuthRejectionClearsNetworkAndAuthThenFailsTrust() async throws { + // The manual flow's pre-connect `mobile.attach_ticket.create` probe reaches + // the Mac. A host rejection there (account mismatch) must clear the network + // and authentication gates, not show them untested (issue #6084 follow-up). + // A loopback host skips the offline preflight and takes the stack-auth + // attach-ticket path. + let runtime = LivenessTestRuntime( + transportFactory: ChecklistErrorTransportFactory(code: "account_mismatch", message: "different account"), + now: { TestClock().now }, + pairingRequestTimeoutNanoseconds: 5_000_000_000 + ) + let store = MobileShellComposite.preview(runtime: runtime) + store.signIn() + await store.connectManualHost(name: "Work Mac", host: "127.0.0.1", port: 58_465) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication == .succeeded) + #expect(checklist.trust.isFailed) + } + @Test func preSendTokenFailureLeavesNetworkGateUntested() async throws { // The Stack token provider fails, so the request never reaches the // transport. The auth gate fails, but the network gate must stay untested From 087f1a6cbabdbc1e55b71db23de62e0e6bd3a19f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 02:31:28 -0700 Subject: [PATCH 07/17] Pairing checklist model: one type per file + DocC on public members Address autoreview (Aziz) policy findings: split MobilePairingChecklist.swift into MobilePairingStage.swift, MobilePairingStageStatus.swift, and MobilePairingChecklist.swift (matching the package's one-type-per-file convention), and add the missing Swift-DocC comments to the checklist's public properties and initializer. (Co-located private SwiftUI sub-views like PairingChecklistRow and the shared test-doubles files are left as-is, consistent with existing patterns such as AddDeviceField in PairingView.swift and the multi-double TransportTestDoubles.swift.) Co-Authored-By: Claude Opus 4.8 --- .../MobilePairingChecklist.swift | 69 +++---------------- .../MobilePairingStage.swift | 27 ++++++++ .../MobilePairingStageStatus.swift | 33 +++++++++ 3 files changed, 70 insertions(+), 59 deletions(-) create mode 100644 Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift create mode 100644 Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStageStatus.swift diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift index 8fc8aef27075..d45c42f0a3da 100644 --- a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift @@ -1,71 +1,22 @@ import Foundation -/// One of the three discrete gates a pairing attempt must clear, in the order -/// they are attempted. Surfacing each as its own check mark lets the user tell -/// exactly which stage succeeded or failed instead of reading one opaque -/// "could not connect" (https://github.com/manaflow-ai/cmux/issues/6084). -public enum MobilePairingStage: Equatable, Sendable, CaseIterable { - /// Reaching the Mac over the network: reachability, routing, the listener, - /// and opening the transport to the address the pairing code points at. The - /// first gate — nothing else can be attempted until it clears. - case network - /// Verifying this device's signed-in account credential with the Mac. - case authentication - /// Confirming the Mac belongs to the same cmux account, over a route trusted - /// to carry that credential. The last gate. - case trust - - /// Position in the attempt order, used to decide which gates an earlier - /// failure leaves untested (`.pending`) versus provably cleared. - public var order: Int { - switch self { - case .network: return 0 - case .authentication: return 1 - case .trust: return 2 - } - } -} - -/// The resolution state of a single pairing gate, mirrored into an individual -/// check mark in the pairing UI. -public enum MobilePairingStageStatus: Equatable, Sendable { - /// Not started, or left untested because an earlier gate has not cleared. - case pending - /// Currently being attempted. - case inProgress - /// Cleared. - case succeeded - /// Failed, carrying the localized headline and optional actionable guidance - /// the UI shows beneath this gate's row. - case failed(message: String, guidance: String?) - - /// Whether this gate is the one that failed. - public var isFailed: Bool { - if case .failed = self { return true } - return false - } - - /// The failure headline, when this gate failed. - public var failureMessage: String? { - if case let .failed(message, _) = self { return message } - return nil - } - - /// The actionable next-step line, when this gate failed and one applies. - public var failureGuidance: String? { - if case let .failed(_, guidance) = self { return guidance } - return nil - } -} - /// The per-gate status of the network / authentication / trust pairing /// checklist. A value type so the whole "how far did pairing get" projection is -/// computed in one place and rendered as plain immutable data by the UI. +/// computed in one place and rendered as plain immutable data by the UI +/// (https://github.com/manaflow-ai/cmux/issues/6084). public struct MobilePairingChecklist: Equatable, Sendable { + /// Status of the network gate (reaching the Mac). public var network: MobilePairingStageStatus + /// Status of the authentication gate (verifying this device's account). public var authentication: MobilePairingStageStatus + /// Status of the trust gate (confirming it's the right Mac on a trusted route). public var trust: MobilePairingStageStatus + /// Create a checklist from an explicit status for each gate. + /// - Parameters: + /// - network: Status of the network gate. + /// - authentication: Status of the authentication gate. + /// - trust: Status of the trust gate. public init( network: MobilePairingStageStatus, authentication: MobilePairingStageStatus, diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift new file mode 100644 index 000000000000..4ff645d82741 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift @@ -0,0 +1,27 @@ +import Foundation + +/// One of the three discrete gates a pairing attempt must clear, in the order +/// they are attempted. Surfacing each as its own check mark lets the user tell +/// exactly which stage succeeded or failed instead of reading one opaque +/// "could not connect" (https://github.com/manaflow-ai/cmux/issues/6084). +public enum MobilePairingStage: Equatable, Sendable, CaseIterable { + /// Reaching the Mac over the network: reachability, routing, the listener, + /// and opening the transport to the address the pairing code points at. The + /// first gate — nothing else can be attempted until it clears. + case network + /// Verifying this device's signed-in account credential with the Mac. + case authentication + /// Confirming the Mac belongs to the same cmux account, over a route trusted + /// to carry that credential. The last gate. + case trust + + /// Position in the attempt order, used to decide which gates an earlier + /// failure leaves untested (`.pending`) versus provably cleared. + public var order: Int { + switch self { + case .network: return 0 + case .authentication: return 1 + case .trust: return 2 + } + } +} diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStageStatus.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStageStatus.swift new file mode 100644 index 000000000000..0207aa934863 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStageStatus.swift @@ -0,0 +1,33 @@ +import Foundation + +/// The resolution state of a single pairing gate, mirrored into an individual +/// check mark in the pairing UI (https://github.com/manaflow-ai/cmux/issues/6084). +public enum MobilePairingStageStatus: Equatable, Sendable { + /// Not started, or left untested because an earlier gate has not cleared. + case pending + /// Currently being attempted. + case inProgress + /// Cleared. + case succeeded + /// Failed, carrying the localized headline and optional actionable guidance + /// the UI shows beneath this gate's row. + case failed(message: String, guidance: String?) + + /// Whether this gate is the one that failed. + public var isFailed: Bool { + if case .failed = self { return true } + return false + } + + /// The failure headline, when this gate failed. + public var failureMessage: String? { + if case let .failed(message, _) = self { return message } + return nil + } + + /// The actionable next-step line, when this gate failed and one applies. + public var failureGuidance: String? { + if case let .failed(_, guidance) = self { return guidance } + return nil + } +} From 90aae2fd49680ad2394b14853b89389ac4fbd9fd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 03:08:53 -0700 Subject: [PATCH 08/17] Pairing checklist: a successful attach-ticket probe also marks the Mac reached Autoreview follow-up: `requestManualAttachTicket` only recorded the reached-Mac signal on its catch path. A successful `mobile.attach_ticket.create` round trip also proves the Mac was reached, so if the subsequent `connect(ticket:)` failed locally before sending (e.g. the Stack token provider fails on the workspace-list request), the checklist showed Network untested. Set `pairingAttemptReachedMac` on the probe's success path too (with the same generation check). Adds a regression test driving a successful probe followed by a pre-send token failure in `connect`. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../MobileShellComposite.swift | 6 ++ .../MobileShellCompositeChecklistTests.swift | 92 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 266db9fcb07c..aeb8ad3938fd 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -20,7 +20,7 @@ 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift -5232 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5238 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4921 Sources/cmuxApp.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index d284d7a29d0b..1e55ce4dbb52 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2464,6 +2464,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } throw error } + // A successful round trip also proves the Mac was reached, so a later local + // failure in `connect(ticket:)` (e.g. a pre-send token failure on the + // workspace-list request) still resolves with the network gate cleared. + if isCurrentConnectionAttempt(generation) { + pairingAttemptReachedMac = true + } let response = try MobileManualAttachTicketCreateResponse.decode(resultData) return try response.ticket.constrainingRoutes(to: [route], fallbackDisplayName: displayName) } diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift index e459ed47e3fe..5857f2d2a70d 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -81,6 +81,27 @@ import Testing #expect(checklist.trust.isFailed) } + @Test func manualProbeSuccessThenConnectLocalFailureClearsNetworkGate() async throws { + // The attach-ticket probe succeeds (reaching the Mac), then connect's first + // request fails locally (Stack token unavailable on the second call). The + // network gate must stay cleared from the successful probe, not revert to + // untested (issue #6084 follow-up). + let provider = FirstCallSucceedsTokenProvider() + let ticket = try makeTicket(clock: TestClock()) + let runtime = LivenessTestRuntime( + transportFactory: AttachTicketSuccessTransportFactory(ticket: ticket), + stackAccessTokenProvider: { try provider.next() }, + stackAccessTokenForceRefresher: { throw FirstCallSucceedsTokenProvider.TokenError() }, + now: { TestClock().now } + ) + let store = MobileShellComposite.preview(runtime: runtime) + store.signIn() + await store.connectManualHost(name: "Work Mac", host: "127.0.0.1", port: 58_465) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication.isFailed) + } + @Test func preSendTokenFailureLeavesNetworkGateUntested() async throws { // The Stack token provider fails, so the request never reaches the // transport. The auth gate fails, but the network gate must stay untested @@ -217,6 +238,77 @@ struct ConnectFailingTransportFactory: CmxByteTransportFactory { } } +/// A transport that answers any framed request with a successful +/// `mobile.attach_ticket.create` response carrying `ticket`, so the manual-host +/// pre-connect probe succeeds (and thereby reaches the Mac). +actor AttachTicketSuccessTransport: CmxByteTransport { + private let ticket: CmxAttachTicket + private var pendingFrames: [Data] = [] + private var receiveWaiters: [CheckedContinuation] = [] + private var isClosed = false + + init(ticket: CmxAttachTicket) { + self.ticket = ticket + } + + func connect() async throws {} + + func receive() async throws -> Data? { + if !pendingFrames.isEmpty { + return pendingFrames.removeFirst() + } + if isClosed { + return nil + } + return await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func send(_ data: Data) async throws { + var buffer = data + let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + for payload in payloads { + let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] + guard let id = parsed?["id"] as? String, + let ticketData = try? encoder.encode(ticket), + let ticketJSON = try? JSONSerialization.jsonObject(with: ticketData) else { continue } + let envelope: [String: Any] = ["id": id, "ok": true, "result": ["ticket": ticketJSON]] + guard let frame = try? MobileSyncFrameCodec.encodeFrame( + JSONSerialization.data(withJSONObject: envelope) + ) else { continue } + deliver(frame) + } + } + + func close() async { + isClosed = true + let waiters = receiveWaiters + receiveWaiters = [] + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + private func deliver(_ frame: Data) { + if receiveWaiters.isEmpty { + pendingFrames.append(frame) + return + } + receiveWaiters.removeFirst().resume(returning: frame) + } +} + +struct AttachTicketSuccessTransportFactory: CmxByteTransportFactory { + let ticket: CmxAttachTicket + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + AttachTicketSuccessTransport(ticket: ticket) + } +} + /// Reports a fixed online/offline verdict and never emits a path change, for the /// reachability preflight test. struct StubReachability: ReachabilityProviding { From d27db55d8ed074b96fe88d6246b8ab96407ecfbe Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 03:16:06 -0700 Subject: [PATCH 09/17] Pairing checklist: a superseding background attempt clears it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autoreview follow-up: the foreground/background gate was applied only when publishing, so a non-foreground attempt (background reconnect, host switch, device-tree tap) that superseded an in-flight Add Device attempt left the old `pairingChecklist` (e.g. a stuck `.connecting`) in the store. In PairingView a non-nil checklist hides `connectionError`, so the user could see a stale spinner with the real failure suppressed. Make the checklist attempt-scoped like `connectionError`: reset it at the start of every instrumented attempt — a foreground attempt starts the network gate, any other attempt clears it. Adds a regression test that a superseding background attempt clears a foreground checklist. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../CmuxMobileShell/MobileShellComposite.swift | 13 +++++++------ .../MobileShellCompositeChecklistTests.swift | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index aeb8ad3938fd..92b70dbcd2e3 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -20,7 +20,7 @@ 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift -5238 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5239 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4921 Sources/cmuxApp.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 1e55ce4dbb52..d87850ca8b50 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -3367,13 +3367,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionErrorGuidance = nil } - /// Start the pairing checklist for an instrumented attempt: the network gate - /// is being attempted, the later gates wait their turn. No-op for a - /// non-foreground attempt (background reconnect / host switch) so it never - /// overwrites the foreground sheet's checklist. + /// Reset the pairing checklist at the start of an instrumented attempt, so it + /// always reflects the current attempt (mirroring ``clearPairingError``). A + /// foreground Add Device attempt starts the network gate; any other attempt + /// (background reconnect, host switch, device-tree tap) clears it so a + /// superseded foreground attempt's stale spinner/result can't linger in the + /// Add Device sheet and hide the real connection error (issue #6084). private func beginPairingChecklist() { - guard isForegroundPairingAttempt else { return } - pairingChecklist = .connecting + pairingChecklist = isForegroundPairingAttempt ? .connecting : nil } /// Project a classified failure onto the per-gate checklist. Gated on a diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift index 5857f2d2a70d..37d3de95a580 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -41,6 +41,24 @@ import Testing #expect(store.connectionError != nil) } + @Test func supersedingBackgroundAttemptClearsForegroundChecklist() async throws { + // A foreground attempt publishes a checklist; a later background attempt + // (reconnect / host switch) that supersedes it must clear the stale + // checklist so it can't keep hiding the real error in the Add Device sheet + // (issue #6084 follow-up). + let store = MobileShellComposite(reachability: StubReachability(online: false)) + store.signIn() + await store.connectManualHost(name: "Work Mac", host: "100.64.0.1", port: 58_465) + #expect(store.pairingChecklist != nil) + await store.performConnectManualHost( + name: "Stored Mac", + host: "100.64.0.2", + port: 58_465, + isForegroundPairing: false + ) + #expect(store.pairingChecklist == nil) + } + @Test func authRejectionClearsNetworkThenFailsAuthenticationGate() async throws { let store = makeStore(errorCode: "unauthorized", message: "invalid token") let result = await connectAcceptingVersionWarning(store, try attachURL(for: makeTicket(clock: TestClock()))) From 9e1005083626363cec165e1100b1bed438f6f347 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:20:40 -0700 Subject: [PATCH 10/17] fix: address pairing checklist review feedback --- .../MobileShellComposite.swift | 7 ++-- .../PairingChecklistView.swift | 19 +++++++++- .../CmuxMobileShellUI/PairingView.swift | 36 +++++++++++++------ Resources/Localizable.xcstrings | 2 +- ios/cmux/Resources/Localizable.xcstrings | 2 +- 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index d87850ca8b50..48c41128e77c 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -140,10 +140,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// most recent pairing attempt, surfaced as individual check marks in /// ``PairingView`` so the user can see exactly which stage succeeded or failed /// (https://github.com/manaflow-ai/cmux/issues/6084). `nil` before any - /// attempt. Painted for every instrumented attempt (including background - /// reconnects); ``PairingView`` only renders it once the user starts a - /// foreground pairing attempt, so a background reconnect never shows a stale - /// checklist. + /// attempt. Only foreground Add Device attempts publish it; background + /// reconnects, host switches, and device-tree taps clear or skip it so they + /// cannot repaint the foreground sheet with stale checklist state. public private(set) var pairingChecklist: MobilePairingChecklist? /// A warning that must be accepted before pairing continues, currently used /// for Mac/iPhone app-version skew. diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift index e10b8896408a..636b0e991e00 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift @@ -52,9 +52,11 @@ private struct PairingChecklistRow: View { Spacer(minLength: 0) } - .accessibilityElement(children: .combine) + .accessibilityElement(children: .ignore) + .accessibilityLabel(stage.title) .accessibilityIdentifier("MobilePairingChecklistRow.\(stage.accessibilityIdentifierSuffix)") .accessibilityValue(status.accessibilityValue) + .accessibilityHint(accessibilityHint) } @ViewBuilder @@ -63,10 +65,25 @@ private struct PairingChecklistRow: View { case .inProgress: ProgressView() .controlSize(.small) + .accessibilityHidden(true) default: Image(systemName: status.symbolName) .font(.title3) .foregroundStyle(status.tintColor) + .accessibilityHidden(true) + } + } + + private var accessibilityHint: String { + switch (status.failureMessage, status.failureGuidance) { + case let (message?, guidance?): + return "\(message) \(guidance)" + case let (message?, nil): + return message + case let (nil, guidance?): + return guidance + case (nil, nil): + return stage.detail } } } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift index ac18c8edee1b..3ee1661e9084 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift @@ -20,8 +20,8 @@ struct PairingView: View { let connectionErrorGuidance: String? /// Per-gate (network / authentication / trust) status of the current pairing /// attempt, shown as individual check marks. Rendered only after the user - /// starts a pairing attempt from this screen (see ``hasStartedPairing``), so a - /// background reconnect never surfaces a stale checklist here. + /// starts a pairing attempt from this screen with the current route inputs, so + /// a background reconnect never surfaces a stale checklist here. let pairingChecklist: MobilePairingChecklist? let versionWarning: String? let connectPairingCode: () async -> Void @@ -40,10 +40,9 @@ struct PairingView: View { @Environment(\.tailscaleStatusMonitor) private var tailscaleStatusMonitor @State private var validationError: String? @State private var isPairing = false - /// Set the first time the user starts a pairing attempt from this screen, so - /// the connection checklist appears only for an attempt the user initiated - /// here — never for a stale background reconnect's result. - @State private var hasStartedPairing = false + /// Route inputs captured when the user starts pairing from this screen, so + /// editing the host, port, or QR/link value hides any prior attempt result. + @State private var startedPairingInputSignature: PairingInputSignature? @State private var pairingTaskID: UUID? @State private var pairingTask: Task? @FocusState private var focusedField: AddDeviceField? @@ -283,10 +282,13 @@ struct PairingView: View { } /// Whether to show the network / authentication / trust checklist. Only for - /// an attempt the user started on this screen, and never alongside a local - /// form-validation error (which supersedes a prior attempt's result). + /// an attempt the user started on this screen with the current route inputs, + /// and never alongside a local form-validation error (which supersedes a prior + /// attempt's result). private var showsChecklist: Bool { - validationError == nil && hasStartedPairing && pairingChecklist != nil + validationError == nil + && startedPairingInputSignature == currentPairingInputSignature + && pairingChecklist != nil } private var errorText: String? { @@ -340,6 +342,14 @@ struct PairingView: View { return String(format: format, email) } + private var currentPairingInputSignature: PairingInputSignature { + PairingInputSignature( + pairingCode: pairingCode.trimmingCharacters(in: .whitespacesAndNewlines), + host: host.trimmingCharacters(in: .whitespacesAndNewlines), + port: port.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + private func pair() { validationError = nil let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) @@ -374,7 +384,7 @@ struct PairingView: View { let taskID = UUID() pairingTaskID = taskID isPairing = true - hasStartedPairing = true + startedPairingInputSignature = currentPairingInputSignature let task = Task { @MainActor in defer { if pairingTaskID == taskID { @@ -394,3 +404,9 @@ private enum AddDeviceField: Hashable { case host case port } + +private struct PairingInputSignature: Equatable { + let pairingCode: String + let host: String + let port: String +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index dcf64cf473e1..644be55b891e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -198459,7 +198459,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "あなたの Mac か確認" + "value": "あなたの Mac であることを確認" } } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 7c52e74ca867..5508ff585921 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -6337,7 +6337,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "あなたの Mac か確認" + "value": "あなたの Mac であることを確認" } } } From 43497c8aee7d9ff52075dabcaea81984311064e4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:38:41 -0700 Subject: [PATCH 11/17] fix: regenerate Swift file length budget --- .github/swift-file-length-budget.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index b5a73d38dc82..9fe5232632ba 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -20,7 +20,7 @@ 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift -5239 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5238 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4921 Sources/cmuxApp.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift @@ -118,12 +118,13 @@ 746 Sources/App/MenuBarExtraController.swift 738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift 736 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 716 Sources/TaskManagerSnapshot.swift 715 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift -706 CLI/CMUXCLI+Config.swift 707 CLI/CMUXCLI+AgentHookDefinitions.swift +706 CLI/CMUXCLI+Config.swift 699 Sources/RightSidebarPanelView.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -187,7 +188,6 @@ 528 cmuxTests/CLINotifyProcessTestSupport.swift 528 cmuxUITests/AutomationSocketUITests.swift 527 CLI/CLISocketPathResolver.swift -726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 523 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 520 CLI/CMUXCLI+AmpExtension.swift 520 cmuxTests/MainWindowVisibilityControllerTests.swift From 7dab463591aa55c5da1cf7b8849a87bc9acbbf81 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:56:53 -0700 Subject: [PATCH 12/17] fix: regenerate Swift file length budget --- .github/swift-file-length-budget.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 9fe5232632ba..2de67787b928 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33857 CLI/cmux.swift 17914 Sources/AppDelegate.swift -16740 Sources/ContentView.swift +16709 Sources/ContentView.swift 14612 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift @@ -13,8 +13,8 @@ 7911 Sources/Panels/BrowserPanelView.swift 7350 cmuxTests/WorkspaceUnitTests.swift 6944 cmuxTests/WorkspaceRemoteConnectionTests.swift +6363 cmuxTests/GhosttyConfigTests.swift 6317 cmuxTests/SessionPersistenceTests.swift -6299 cmuxTests/GhosttyConfigTests.swift 6153 CLI/cmux_open.swift 6074 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift From f89a492a8cb804da84df69ae5e18081c20935acb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 13:30:46 -0700 Subject: [PATCH 13/17] fix: clear pairing checklist on manual validation failures --- .../MobileShellComposite.swift | 4 +-- .../MobileShellCompositeChecklistTests.swift | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 48c41128e77c..093a46b54c36 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1114,8 +1114,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } public func connectManualHost(name: String, host: String, port: Int) async { - // The public entry is the Add Device "Pair" button — a foreground pairing - // attempt that owns the network/auth/trust checklist. await performConnectManualHost(name: name, host: host, port: port, isForegroundPairing: true) } @@ -1131,6 +1129,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { isForegroundPairingAttempt = isForegroundPairing let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else { + clearPairingChecklist() connectionError = L10n.string("mobile.addDevice.invalidHost", defaultValue: "Enter a host or IP address, without spaces or URL paths.") connectionErrorGuidance = nil connectionState = .disconnected @@ -1145,6 +1144,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } guard (1...65535).contains(port) else { + clearPairingChecklist() connectionError = L10n.string("mobile.addDevice.invalidPort", defaultValue: "Enter a port from 1 to 65535.") connectionErrorGuidance = nil connectionState = .disconnected diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift index 37d3de95a580..4942b816316c 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -59,6 +59,36 @@ import Testing #expect(store.pairingChecklist == nil) } + @Test func backgroundInvalidManualHostClearsForegroundChecklist() async throws { + let store = MobileShellComposite(reachability: StubReachability(online: false)) + store.signIn() + await store.connectManualHost(name: "Work Mac", host: "100.64.0.1", port: 58_465) + #expect(store.pairingChecklist != nil) + await store.performConnectManualHost( + name: "Stored Mac", + host: "bad host", + port: 58_465, + isForegroundPairing: false + ) + #expect(store.pairingChecklist == nil) + #expect(store.connectionError != nil) + } + + @Test func backgroundInvalidManualPortClearsForegroundChecklist() async throws { + let store = MobileShellComposite(reachability: StubReachability(online: false)) + store.signIn() + await store.connectManualHost(name: "Work Mac", host: "100.64.0.1", port: 58_465) + #expect(store.pairingChecklist != nil) + await store.performConnectManualHost( + name: "Stored Mac", + host: "100.64.0.2", + port: 0, + isForegroundPairing: false + ) + #expect(store.pairingChecklist == nil) + #expect(store.connectionError != nil) + } + @Test func authRejectionClearsNetworkThenFailsAuthenticationGate() async throws { let store = makeStore(errorCode: "unauthorized", message: "invalid token") let result = await connectAcceptingVersionWarning(store, try attachURL(for: makeTicket(clock: TestClock()))) From 5abc51a05640a08b20b8e6b138573e48faccd88f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 18 Jun 2026 12:10:25 -0700 Subject: [PATCH 14/17] refactor: split mobile pairing checklist helpers --- .github/swift-file-length-budget.tsv | 2 +- .../CmxAttachTicket+ConstrainingRoutes.swift | 24 +++ ...bileManualAttachTicketCreateResponse.swift | 12 ++ .../MobilePairingChecklistResolution.swift | 56 ++++++ .../MobilePairingChecklistState.swift | 38 ++++ .../MobilePairingFailure.swift | 99 --------- .../MobileShellComposite+PairingFormat.swift | 39 ++++ .../MobileShellComposite.swift | 189 ++---------------- 8 files changed, 189 insertions(+), 270 deletions(-) create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistState.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairingFormat.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 44a60b1ce904..8994c3ba7409 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -20,7 +20,7 @@ 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5526 cmuxTests/BrowserConfigTests.swift -5238 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5087 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4920 Sources/cmuxApp.swift 4467 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift new file mode 100644 index 000000000000..8950b90a7801 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift @@ -0,0 +1,24 @@ +import CMUXMobileCore +import Foundation + +extension CmxAttachTicket { + func constrainingRoutes( + to routes: [CmxAttachRoute], + fallbackDisplayName: String + ) throws -> CmxAttachTicket { + try CmxAttachTicket( + workspaceID: workspaceID, + terminalID: terminalID, + macDeviceID: macDeviceID, + macDisplayName: macDisplayName ?? fallbackDisplayName, + macUserEmail: macUserEmail, + macUserID: macUserID, + macPairingCompatibilityVersion: macPairingCompatibilityVersion, + macAppVersion: macAppVersion, + macAppBuild: macAppBuild, + routes: routes, + expiresAt: expiresAt, + authToken: authToken + ) + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift new file mode 100644 index 000000000000..fc1251d71d28 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift @@ -0,0 +1,12 @@ +import CMUXMobileCore +import Foundation + +struct MobileManualAttachTicketCreateResponse: Decodable, Sendable { + var ticket: CmxAttachTicket + + static func decode(_ data: Data) throws -> MobileManualAttachTicketCreateResponse { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(MobileManualAttachTicketCreateResponse.self, from: data) + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift new file mode 100644 index 000000000000..8f0e463cde2c --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift @@ -0,0 +1,56 @@ +import CmuxMobileShellModel + +extension MobilePairingFailureCategory { + /// Which pairing gate this failure belongs to. `nil` only for cancellation. + var stage: MobilePairingStage? { + switch self { + case .offline, .hostUnreachable, .listenerNotRunning, .localNetworkBlocked, + .dnsFailed, .handshakeTimedOut, .connectionDropped, .invalidCode, + .loopbackRejected, .noSupportedRoute, .unknown: + return .network + case .authFailed, .ticketExpired: + return .authentication + case .accountMismatch, .emailMismatch, .unsupportedRoute: + return .trust + case .cancelled: + return nil + } + } + + /// Whether an on-the-wire occurrence proves every earlier gate already passed. + var clearsPriorGates: Bool { + switch self { + case .authFailed, .ticketExpired, .accountMismatch: + return true + default: + return false + } + } +} + +extension MobilePairingChecklist { + /// Build the resolved checklist for a failed attempt. + static func resolving( + _ category: MobilePairingFailureCategory, + reachedMac: Bool + ) -> MobilePairingChecklist { + guard let failedStage = category.stage else { + return MobilePairingChecklist(network: .pending, authentication: .pending, trust: .pending) + } + let failure = MobilePairingStageStatus.failed( + message: category.message, + guidance: category.guidance + ) + let priorCleared = reachedMac && category.clearsPriorGates + func status(for stage: MobilePairingStage) -> MobilePairingStageStatus { + if stage == failedStage { return failure } + if stage.order < failedStage.order { return priorCleared ? .succeeded : .pending } + return .pending + } + return MobilePairingChecklist( + network: status(for: .network), + authentication: status(for: .authentication), + trust: status(for: .trust) + ) + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistState.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistState.swift new file mode 100644 index 000000000000..078b16259663 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistState.swift @@ -0,0 +1,38 @@ +import CmuxMobileShellModel + +/// Owns the foreground-only checklist projection for one pairing attempt. +struct MobilePairingChecklistState { + private(set) var checklist: MobilePairingChecklist? + private var isForegroundAttempt = false + private var reachedMac = false + + mutating func setForegroundAttempt(_ value: Bool) { + isForegroundAttempt = value + } + + mutating func beginValidationAttempt(hasMethod: Bool) { + reachedMac = false + checklist = hasMethod && isForegroundAttempt ? .connecting : nil + } + + mutating func markReachedMac() { + reachedMac = true + } + + mutating func resolveFailure( + _ category: MobilePairingFailureCategory, + hasInstrumentedAttempt: Bool + ) { + guard isForegroundAttempt, hasInstrumentedAttempt else { return } + checklist = .resolving(category, reachedMac: reachedMac) + } + + mutating func markConnected() { + guard isForegroundAttempt else { return } + checklist = .connected + } + + mutating func clearChecklist() { + checklist = nil + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index 75efc6751b2e..d9c51354b40b 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -1,6 +1,5 @@ public import CMUXMobileCore internal import CmuxMobileRPC -internal import CmuxMobileShellModel internal import CmuxMobileSupport internal import CmuxMobileTransport import Foundation @@ -389,101 +388,3 @@ extension MobilePairingFailureCategory { return String(format: L10n.string(key, defaultValue: defaultValue), host, port) } } - -extension MobilePairingFailureCategory { - /// Which of the three pairing gates (network / authentication / trust) this - /// failure belongs to, so the pairing checklist can mark the right check mark - /// red. `nil` only for ``cancelled`` (not a user-visible failure). - /// - /// The grouping is by gate, not by where in the code the failure is detected: - /// - **network** owns everything about establishing a usable transport to the - /// Mac, including the inputs that make that impossible before a packet is - /// sent (an invalid/loopback code, or no route this build can dial). These - /// all mean "this device could not reach a Mac". - /// - **authentication** owns the credential being rejected on the wire - /// (invalid/expired token or attach ticket). - /// - **trust** owns the security relationship: the Mac is a different account - /// (``accountMismatch``), the pairing code was minted for a different email - /// than this device (``emailMismatch``), or the route is not trusted to - /// carry the credential (``unsupportedRoute``). - var stage: MobilePairingStage? { - switch self { - case .offline, .hostUnreachable, .listenerNotRunning, .localNetworkBlocked, - .dnsFailed, .handshakeTimedOut, .connectionDropped, .invalidCode, - .loopbackRejected, .noSupportedRoute, .unknown: - return .network - case .authFailed, .ticketExpired: - return .authentication - case .accountMismatch, .emailMismatch, .unsupportedRoute: - return .trust - case .cancelled: - return nil - } - } - - /// Whether an *on-the-wire* occurrence of this failure proves every gate - /// before ``stage`` was already cleared. A rejection the Mac sends back proves - /// the device reached it (network cleared) and, for an account mismatch, that - /// the credential was read (authentication cleared). Transport failures, an - /// invalid code, a route refused client-side as untrusted (``unsupportedRoute``), - /// and the email/identity mismatch caught client-side from the ticket - /// (``emailMismatch``) prove nothing, so their earlier gates stay - /// ``MobilePairingStageStatus/pending`` (untested). - /// - /// This is only valid when the attempt actually reached the wire; the same - /// ``authFailed`` can be raised pre-network by the ticket-identity preflight, - /// where it has cleared nothing. ``MobilePairingChecklist/resolving(_:reachedMac:)`` - /// gates this with `reachedMac` so a pre-network rejection never shows a false - /// network check mark. - var clearsPriorGates: Bool { - switch self { - case .authFailed, .ticketExpired, .accountMismatch: - return true - default: - return false - } - } -} - -extension MobilePairingChecklist { - /// Build the resolved checklist for a failed attempt: the gate the failure - /// belongs to shows the headline + guidance, every gate the failure proves - /// was cleared shows a check mark, and every other gate stays untested. This - /// is the single projection from "why did pairing fail" to "which check marks - /// the user sees", so it is pure and unit-tested without a live connection. - /// - /// - Parameters: - /// - category: The classified failure. - /// - reachedMac: Whether the attempt actually got a request onto the - /// transport to the Mac, so a gate before the failed one that - /// ``MobilePairingFailureCategory/clearsPriorGates`` marks cleared really - /// was. A failure that never reached the transport — offline, a bad code, - /// or a local pre-send token/ticket failure — passes `false`, leaving the - /// earlier gates untested instead of falsely cleared. - static func resolving( - _ category: MobilePairingFailureCategory, - reachedMac: Bool - ) -> MobilePairingChecklist { - guard let failedStage = category.stage else { - // `.cancelled` is handled by the `catch is CancellationError` branches - // before classification, so this is only defensive: a cancelled - // attempt resolves nothing. - return MobilePairingChecklist(network: .pending, authentication: .pending, trust: .pending) - } - let failure = MobilePairingStageStatus.failed( - message: category.message, - guidance: category.guidance - ) - let priorCleared = reachedMac && category.clearsPriorGates - func status(for stage: MobilePairingStage) -> MobilePairingStageStatus { - if stage == failedStage { return failure } - if stage.order < failedStage.order { return priorCleared ? .succeeded : .pending } - return .pending - } - return MobilePairingChecklist( - network: status(for: .network), - authentication: status(for: .authentication), - trust: status(for: .trust) - ) - } -} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairingFormat.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairingFormat.swift new file mode 100644 index 000000000000..7152e3693697 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairingFormat.swift @@ -0,0 +1,39 @@ +import CmuxMobileSupport +import Foundation + +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))" + } + + 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)" + ) + } + + static func mobileShellNormalizedEmail(_ value: String?) -> String? { + mobileShellNormalizedNonEmpty(value)?.lowercased() + } + + static func mobileShellNormalizedNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 093a46b54c36..0b9fea9f567e 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -136,14 +136,8 @@ 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? - /// The per-gate status (network / authentication / trust) of the in-flight or - /// most recent pairing attempt, surfaced as individual check marks in - /// ``PairingView`` so the user can see exactly which stage succeeded or failed - /// (https://github.com/manaflow-ai/cmux/issues/6084). `nil` before any - /// attempt. Only foreground Add Device attempts publish it; background - /// reconnects, host switches, and device-tree taps clear or skip it so they - /// cannot repaint the foreground sheet with stale checklist state. - public private(set) var pairingChecklist: MobilePairingChecklist? + /// The foreground pairing attempt's network / authentication / trust statuses. + public var pairingChecklist: MobilePairingChecklist? { pairingChecklistState.checklist } /// A warning that must be accepted before pairing continues, currently used /// for Mac/iPhone app-version skew. public private(set) var pairingVersionWarning: String? @@ -462,18 +456,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 - /// Whether the in-flight attempt got a request onto the transport (proof it - /// reached the Mac), so the pairing checklist only marks the network gate - /// cleared for an auth/trust failure that the host actually returned — never - /// for a local pre-send token/ticket failure (issue #6084). Reset at each - /// attempt entry; set once `connect()` observes a client that attempted a send. - private var pairingAttemptReachedMac = false - /// Whether the in-flight attempt is a user-initiated pairing from the Add - /// Device flow (QR/link scan or the manual Pair button) rather than a - /// background reconnect, host switch, or device-tree tap. Only foreground - /// attempts publish ``pairingChecklist``, so a background reconnect can never - /// overwrite or render the foreground sheet's checklist (issue #6084). - private var isForegroundPairingAttempt = false + private var pairingChecklistState = MobilePairingChecklistState() private var pendingPairingVersionWarningURL: String? /// The structured diagnostic log, injected from the app composition root. @@ -660,7 +643,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.terminalInputText = "" self.connectionError = nil self.connectionErrorGuidance = nil - self.pairingChecklist = nil self.pairingVersionWarning = nil self.activeTicket = nil self.activeRoute = nil @@ -1126,10 +1108,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { port: Int, isForegroundPairing: Bool ) async { - isForegroundPairingAttempt = isForegroundPairing + pairingChecklistState.setForegroundAttempt(isForegroundPairing) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else { - clearPairingChecklist() + pairingChecklistState.clearChecklist() connectionError = L10n.string("mobile.addDevice.invalidHost", defaultValue: "Enter a host or IP address, without spaces or URL paths.") connectionErrorGuidance = nil connectionState = .disconnected @@ -1144,7 +1126,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } guard (1...65535).contains(port) else { - clearPairingChecklist() + pairingChecklistState.clearChecklist() connectionError = L10n.string("mobile.addDevice.invalidPort", defaultValue: "Enter a port from 1 to 65535.") connectionErrorGuidance = nil connectionState = .disconnected @@ -2158,8 +2140,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ rawValue: String? = nil, acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { - // QR/link pairing is a foreground Add Device attempt: it owns the checklist. - isForegroundPairingAttempt = true + pairingChecklistState.setForegroundAttempt(true) let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) _ = beginPairingValidationAttempt() connectionAttemptGeneration = UUID() @@ -2453,21 +2434,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { timeoutNanoseconds: runtime.pairingRequestTimeoutNanoseconds ) } catch { - // This pre-connect probe reaches the Mac too. If it connected before - // being rejected, record that the Mac was reached so an auth/trust - // rejection here resolves the checklist with the network gate cleared - // (issue #6084). Re-check the generation after the await so a superseded - // attempt can't write the flag back. if await client.didAttemptHostSend(), isCurrentConnectionAttempt(generation) { - pairingAttemptReachedMac = true + pairingChecklistState.markReachedMac() } throw error } - // A successful round trip also proves the Mac was reached, so a later local - // failure in `connect(ticket:)` (e.g. a pre-send token failure on the - // workspace-list request) still resolves with the network gate cleared. if isCurrentConnectionAttempt(generation) { - pairingAttemptReachedMac = true + pairingChecklistState.markReachedMac() } let response = try MobileManualAttachTicketCreateResponse.decode(resultData) return try response.ticket.constrainingRoutes(to: [route], fallbackDisplayName: displayName) @@ -3014,17 +2987,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return nil } catch { lastError = error - // Record whether this attempt got a request onto the transport, - // so the checklist can tell a host rejection (network reached) - // from a local pre-send token/ticket failure (issue #6084). - // Read the per-client signal first, then re-check generation - // before mutating shared state: this `await` can suspend, and a - // newer attempt may have reset `pairingAttemptReachedMac`, so a - // superseded attempt must not write it back. let didReachHost = await client.didAttemptHostSend() guard isCurrentConnectionAttempt(generation) else { return nil } if didReachHost { - pairingAttemptReachedMac = true + pairingChecklistState.markReachedMac() } 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)" @@ -3244,10 +3210,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private func beginPairingValidationAttempt(method: String? = nil) -> UUID { let attemptID = UUID() pairingAttemptID = attemptID - // A fresh attempt has not reached the Mac yet; cleared here (the shared - // funnel for every pairing/validation attempt) so a prior attempt's - // "reached" state can't leak into this one's checklist. - pairingAttemptReachedMac = false + pairingChecklistState.beginValidationAttempt(hasMethod: method != nil) if let method { pairingAttemptStartedAt = runtime?.now() ?? Date() pairingAttemptMethod = method @@ -3259,13 +3222,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { "is_first_pair": .bool(pairingAttemptIsFirstPair), "attempt_id": .string(attemptID.uuidString), ]) - // The network gate is now being attempted; start a fresh checklist so - // a superseding attempt never inherits the prior attempt's check marks. - beginPairingChecklist() } else { pairingAttemptStartedAt = nil pairingAttemptMethod = nil - clearPairingChecklist() } return attemptID } @@ -3274,7 +3233,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// the attempt timing so a later state change can't double-fire. private func recordPairingSucceeded() { guard let method = pairingAttemptMethod else { return } - markPairingChecklistConnected() + pairingChecklistState.markConnected() var props: [String: AnalyticsValue] = [ "method": .string(method), "is_first_pair": .bool(pairingAttemptIsFirstPair), @@ -3327,7 +3286,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairingAttemptID = UUID() pairingAttemptStartedAt = nil pairingAttemptMethod = nil - clearPairingChecklist() + pairingChecklistState.clearChecklist() } /// Apply a classified pairing failure to the user-visible error surface and @@ -3345,10 +3304,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionError = category.message } connectionErrorGuidance = category.guidance - // Resolve before `recordPairingFailed` clears the attempt instrumentation - // (the checklist sink is gated on an in-flight attempt for the same reason - // the analytics emit is). - resolvePairingChecklist(category) + pairingChecklistState.resolveFailure(category, hasInstrumentedAttempt: pairingAttemptMethod != nil) recordPairingFailed(reason: category.analyticsReason, phase: phase) } @@ -3366,41 +3322,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionErrorGuidance = nil } - /// Reset the pairing checklist at the start of an instrumented attempt, so it - /// always reflects the current attempt (mirroring ``clearPairingError``). A - /// foreground Add Device attempt starts the network gate; any other attempt - /// (background reconnect, host switch, device-tree tap) clears it so a - /// superseded foreground attempt's stale spinner/result can't linger in the - /// Add Device sheet and hide the real connection error (issue #6084). - private func beginPairingChecklist() { - pairingChecklist = isForegroundPairingAttempt ? .connecting : nil - } - - /// Project a classified failure onto the per-gate checklist. Gated on a - /// foreground in-flight attempt (``isForegroundPairingAttempt`` + - /// ``pairingAttemptMethod``) so background reconnects, live-connection auth - /// evictions, and operational errors — which reuse the same classifier — never - /// repaint the pairing checklist. Uses ``pairingAttemptReachedMac`` (set only - /// once a request actually reached the transport) rather than the coarse phase - /// label, so a pre-send token/ticket failure never shows a cleared network gate - /// even though it surfaces in the connect/auth phase. - private func resolvePairingChecklist(_ category: MobilePairingFailureCategory) { - guard isForegroundPairingAttempt, pairingAttemptMethod != nil else { return } - pairingChecklist = .resolving(category, reachedMac: pairingAttemptReachedMac) - } - - /// Mark every gate cleared once a foreground attempt connects. - private func markPairingChecklistConnected() { - guard isForegroundPairingAttempt else { return } - pairingChecklist = .connected - } - - /// Drop the checklist on teardown (cancel, sign-out, switch, forget) so the - /// next ``PairingView`` starts clean. - private func clearPairingChecklist() { - pairingChecklist = nil - } - private func clearPairingVersionWarning() { pairingVersionWarning = nil pendingPairingVersionWarningURL = nil @@ -3468,9 +3389,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { applyPairingFailure(category ?? .unknown(host: nil, port: nil), phase: phase) return } - // `connect()` already set the headline (e.g. `noSupportedRoute`); keep the - // checklist in step with that message before the instrumentation clears. - resolvePairingChecklist(category ?? .unknown(host: nil, port: nil)) + pairingChecklistState.resolveFailure( + category ?? .unknown(host: nil, port: nil), + hasInstrumentedAttempt: pairingAttemptMethod != nil + ) recordPairingFailed(reason: category?.analyticsReason ?? "other", phase: phase) } @@ -5066,10 +4988,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionState = .disconnected macConnectionStatus = .unavailable clearRemoteConnectionContext() - // Same in-flight-attempt gate as the analytics emit below: paints the - // failed gate (auth or trust) for a foreground pairing attempt, no-ops for - // a live-connection auth eviction. - resolvePairingChecklist(category) + pairingChecklistState.resolveFailure(category, hasInstrumentedAttempt: pairingAttemptMethod != nil) // Only emits while a pairing attempt is in flight: `recordPairingFailed` // no-ops once `pairingAttemptMethod` is nil (cleared on success and by // `invalidatePairingAttempt`), so live-connection auth failures that @@ -5133,76 +5052,6 @@ private struct MobileTerminalViewportKey: Hashable, Sendable { var terminalID: MobileTerminalPreview.ID } -private struct MobileManualAttachTicketCreateResponse: Decodable, Sendable { - var ticket: CmxAttachTicket - - static func decode(_ data: Data) throws -> MobileManualAttachTicketCreateResponse { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(MobileManualAttachTicketCreateResponse.self, from: data) - } -} - -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))" - } - - 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)" - ) - } - - static func mobileShellNormalizedEmail(_ value: String?) -> String? { - mobileShellNormalizedNonEmpty(value)?.lowercased() - } - - static 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], - fallbackDisplayName: String - ) throws -> CmxAttachTicket { - try CmxAttachTicket( - workspaceID: workspaceID, - terminalID: terminalID, - macDeviceID: macDeviceID, - macDisplayName: macDisplayName ?? fallbackDisplayName, - macUserEmail: macUserEmail, - macUserID: macUserID, - macPairingCompatibilityVersion: macPairingCompatibilityVersion, - macAppVersion: macAppVersion, - macAppBuild: macAppBuild, - routes: routes, - expiresAt: expiresAt, - authToken: authToken - ) - } - -} - private extension MobileWorkspacePreview { var preferredTerminal: MobileTerminalPreview? { terminals.first { $0.isReady && $0.isFocused } From 173b8e42848bff68aedf83e849a7c85b62165866 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 18 Jun 2026 12:24:53 -0700 Subject: [PATCH 15/17] fix: map unrecognized pairing URLs to network gate --- .../CmuxMobileShell/MobilePairingChecklistResolution.swift | 2 +- .../CmuxMobileShellTests/MobilePairingChecklistTests.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift index 8f0e463cde2c..f221da749594 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift @@ -6,7 +6,7 @@ extension MobilePairingFailureCategory { switch self { case .offline, .hostUnreachable, .listenerNotRunning, .localNetworkBlocked, .dnsFailed, .handshakeTimedOut, .connectionDropped, .invalidCode, - .loopbackRejected, .noSupportedRoute, .unknown: + .unrecognizedVersion, .loopbackRejected, .noSupportedRoute, .unknown: return .network case .authFailed, .ticketExpired: return .authentication diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift index 21fac0743818..a7642ecb1e35 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift @@ -28,6 +28,7 @@ import Testing .authFailed, .ticketExpired, .invalidCode, + .unrecognizedVersion, .loopbackRejected, .unsupportedRoute, .noSupportedRoute, @@ -52,6 +53,7 @@ import Testing .handshakeTimedOut(host: "h", port: 1), .connectionDropped(host: "h", port: 1), .invalidCode, + .unrecognizedVersion, .loopbackRejected, .noSupportedRoute, .unknown(host: "h", port: 1), From 65362b547cb2286b0cec39eb6dac7cd3ad1f9efc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 18 Jun 2026 12:37:54 -0700 Subject: [PATCH 16/17] fix: address pairing checklist review findings --- .../Sources/CmuxMobileShellModel/MobilePairingStage.swift | 2 +- ios/cmux/Resources/Localizable.xcstrings | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift index 4ff645d82741..714f10bc1c22 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift @@ -4,7 +4,7 @@ import Foundation /// they are attempted. Surfacing each as its own check mark lets the user tell /// exactly which stage succeeded or failed instead of reading one opaque /// "could not connect" (https://github.com/manaflow-ai/cmux/issues/6084). -public enum MobilePairingStage: Equatable, Sendable, CaseIterable { +public enum MobilePairingStage: Equatable, Hashable, Sendable, CaseIterable { /// Reaching the Mac over the network: reachability, routing, the listener, /// and opening the transport to the address the pairing code points at. The /// first gate — nothing else can be attempted until it clears. diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 67c3a4d41028..b411a8644c63 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -1469,13 +1469,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Invalid pairing code." + "value": "This isn't a cmux pairing QR. Scan the code shown in the Pair iPhone window on your Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングコードが無効です。" + "value": "これはcmuxのペアリングQRコードではありません。Macの「iPhoneをペアリング」ウインドウに表示されているコードをスキャンしてください。" } } } From 06977d0d12cf818cabbe0dbf62972f4f028d183e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 18:09:32 -0700 Subject: [PATCH 17/17] refactor: split pairing checklist helper types --- .../ConnectFailingTransport.swift | 12 ++ .../ConnectFailingTransportError.swift | 1 + .../ConnectFailingTransportFactory.swift | 7 + .../TransportTestDoubles.swift | 18 -- .../AttachTicketSuccessTransport.swift | 65 ++++++ .../AttachTicketSuccessTransportFactory.swift | 9 + .../ChecklistErrorTransport.swift | 66 ++++++ .../ChecklistErrorTransportFactory.swift | 10 + .../ConnectFailingTransport.swift | 10 + .../ConnectFailingTransportError.swift | 1 + .../ConnectFailingTransportFactory.swift | 7 + .../FirstCallSucceedsTokenProvider.swift | 16 ++ .../MobileShellCompositeChecklistTests.swift | 198 +----------------- .../StubReachability.swift | 10 + .../TestStackTokenError.swift | 1 + .../PairingChecklistRow.swift | 70 +++++++ .../PairingChecklistView.swift | 69 ------ .../PairingInputSignature.swift | 5 + .../CmuxMobileShellUI/PairingView.swift | 6 - 19 files changed, 294 insertions(+), 287 deletions(-) create mode 100644 Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransport.swift create mode 100644 Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransportError.swift create mode 100644 Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransportFactory.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AttachTicketSuccessTransport.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AttachTicketSuccessTransportFactory.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ChecklistErrorTransport.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ChecklistErrorTransportFactory.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransport.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransportError.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransportFactory.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/FirstCallSucceedsTokenProvider.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/StubReachability.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TestStackTokenError.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistRow.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingInputSignature.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransport.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransport.swift new file mode 100644 index 000000000000..4dbf3f1925f2 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransport.swift @@ -0,0 +1,12 @@ +import CMUXMobileCore +import Foundation + +/// A transport whose `connect()` always fails, modeling an unreachable route. +/// Used to prove the session never reports a host send when the channel never +/// came up (issue #6084). +actor ConnectFailingTransport: CmxByteTransport { + func connect() async throws { throw ConnectFailingTransportError() } + func receive() async throws -> Data? { nil } + func send(_ data: Data) async throws {} + func close() async {} +} diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransportError.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransportError.swift new file mode 100644 index 000000000000..4dac35477362 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransportError.swift @@ -0,0 +1 @@ +struct ConnectFailingTransportError: Error {} diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransportFactory.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransportFactory.swift new file mode 100644 index 000000000000..403a1bdf2e80 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectFailingTransportFactory.swift @@ -0,0 +1,7 @@ +import CMUXMobileCore + +struct ConnectFailingTransportFactory: CmxByteTransportFactory { + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + ConnectFailingTransport() + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift index 6846224cd00f..778406808ea0 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift @@ -43,24 +43,6 @@ struct TestMobileSyncRuntime: MobileSyncRuntime { struct MissingTestStackAccessToken: Error {} -/// A transport whose `connect()` always fails, modeling an unreachable route. -/// Used to prove the session never reports a host send when the channel never -/// came up (issue #6084). -actor ConnectFailingTransport: CmxByteTransport { - struct ConnectFailed: Error {} - - func connect() async throws { throw ConnectFailed() } - func receive() async throws -> Data? { nil } - func send(_ data: Data) async throws {} - func close() async {} -} - -struct ConnectFailingTransportFactory: CmxByteTransportFactory { - func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { - ConnectFailingTransport() - } -} - /// Async-safe one-shot boolean flag used to observe task progress in tests. actor AsyncFlag { private var value = false diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AttachTicketSuccessTransport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AttachTicketSuccessTransport.swift new file mode 100644 index 000000000000..630a9d65675f --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AttachTicketSuccessTransport.swift @@ -0,0 +1,65 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation + +/// Answers any framed request with a successful `mobile.attach_ticket.create` +/// response carrying `ticket`, so the manual-host pre-connect probe succeeds. +actor AttachTicketSuccessTransport: CmxByteTransport { + private let ticket: CmxAttachTicket + private var pendingFrames: [Data] = [] + private var receiveWaiters: [CheckedContinuation] = [] + private var isClosed = false + + init(ticket: CmxAttachTicket) { + self.ticket = ticket + } + + func connect() async throws {} + + func receive() async throws -> Data? { + if !pendingFrames.isEmpty { + return pendingFrames.removeFirst() + } + if isClosed { + return nil + } + return await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func send(_ data: Data) async throws { + var buffer = data + let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + for payload in payloads { + let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] + guard let id = parsed?["id"] as? String, + let ticketData = try? encoder.encode(ticket), + let ticketJSON = try? JSONSerialization.jsonObject(with: ticketData) else { continue } + let envelope: [String: Any] = ["id": id, "ok": true, "result": ["ticket": ticketJSON]] + guard let frame = try? MobileSyncFrameCodec.encodeFrame( + JSONSerialization.data(withJSONObject: envelope) + ) else { continue } + deliver(frame) + } + } + + func close() async { + isClosed = true + let waiters = receiveWaiters + receiveWaiters = [] + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + private func deliver(_ frame: Data) { + if receiveWaiters.isEmpty { + pendingFrames.append(frame) + return + } + receiveWaiters.removeFirst().resume(returning: frame) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AttachTicketSuccessTransportFactory.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AttachTicketSuccessTransportFactory.swift new file mode 100644 index 000000000000..7775c49fa652 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AttachTicketSuccessTransportFactory.swift @@ -0,0 +1,9 @@ +import CMUXMobileCore + +struct AttachTicketSuccessTransportFactory: CmxByteTransportFactory { + let ticket: CmxAttachTicket + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + AttachTicketSuccessTransport(ticket: ticket) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ChecklistErrorTransport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ChecklistErrorTransport.swift new file mode 100644 index 000000000000..f8c7ece0c18b --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ChecklistErrorTransport.swift @@ -0,0 +1,66 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation + +/// Answers every framed request with one configured RPC error frame. +actor ChecklistErrorTransport: CmxByteTransport { + private let code: String? + private let message: String + private var pendingFrames: [Data] = [] + private var receiveWaiters: [CheckedContinuation] = [] + private var isClosed = false + + init(code: String?, message: String) { + self.code = code + self.message = message + } + + func connect() async throws {} + + func receive() async throws -> Data? { + if !pendingFrames.isEmpty { + return pendingFrames.removeFirst() + } + if isClosed { + return nil + } + return await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func send(_ data: Data) async throws { + var buffer = data + let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) + for payload in payloads { + let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] + guard let id = parsed?["id"] as? String else { continue } + var error: [String: Any] = ["message": message] + if let code { + error["code"] = code + } + let envelope: [String: Any] = ["id": id, "ok": false, "error": error] + guard let frame = try? MobileSyncFrameCodec.encodeFrame( + JSONSerialization.data(withJSONObject: envelope) + ) else { continue } + deliver(frame) + } + } + + func close() async { + isClosed = true + let waiters = receiveWaiters + receiveWaiters = [] + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + private func deliver(_ frame: Data) { + if receiveWaiters.isEmpty { + pendingFrames.append(frame) + return + } + receiveWaiters.removeFirst().resume(returning: frame) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ChecklistErrorTransportFactory.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ChecklistErrorTransportFactory.swift new file mode 100644 index 000000000000..73e1e01f2262 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ChecklistErrorTransportFactory.swift @@ -0,0 +1,10 @@ +import CMUXMobileCore + +struct ChecklistErrorTransportFactory: CmxByteTransportFactory { + let code: String? + let message: String + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + ChecklistErrorTransport(code: code, message: message) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransport.swift new file mode 100644 index 000000000000..dd698bc3f3c5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransport.swift @@ -0,0 +1,10 @@ +import CMUXMobileCore +import Foundation + +/// A transport whose `connect()` always fails, modeling an unreachable route. +actor ConnectFailingTransport: CmxByteTransport { + func connect() async throws { throw ConnectFailingTransportError() } + func receive() async throws -> Data? { nil } + func send(_ data: Data) async throws {} + func close() async {} +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransportError.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransportError.swift new file mode 100644 index 000000000000..4dac35477362 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransportError.swift @@ -0,0 +1 @@ +struct ConnectFailingTransportError: Error {} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransportFactory.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransportFactory.swift new file mode 100644 index 000000000000..403a1bdf2e80 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectFailingTransportFactory.swift @@ -0,0 +1,7 @@ +import CMUXMobileCore + +struct ConnectFailingTransportFactory: CmxByteTransportFactory { + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + ConnectFailingTransport() + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/FirstCallSucceedsTokenProvider.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/FirstCallSucceedsTokenProvider.swift new file mode 100644 index 000000000000..7c792d05d8cf --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/FirstCallSucceedsTokenProvider.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Uses a private lock to guard the one-shot counter for concurrent test token requests. +final class FirstCallSucceedsTokenProvider: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func next() throws -> String { + let n: Int = lock.withLock { + count += 1 + return count + } + guard n == 1 else { throw TestStackTokenError() } + return "token-1" + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift index 4942b816316c..8ab4fc9ba659 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -139,7 +139,7 @@ import Testing let runtime = LivenessTestRuntime( transportFactory: AttachTicketSuccessTransportFactory(ticket: ticket), stackAccessTokenProvider: { try provider.next() }, - stackAccessTokenForceRefresher: { throw FirstCallSucceedsTokenProvider.TokenError() }, + stackAccessTokenForceRefresher: { throw TestStackTokenError() }, now: { TestClock().now } ) let store = MobileShellComposite.preview(runtime: runtime) @@ -154,11 +154,10 @@ import Testing // The Stack token provider fails, so the request never reaches the // transport. The auth gate fails, but the network gate must stay untested // (not falsely cleared) since no packet left the device (issue #6084). - struct TokenError: Error {} let runtime = LivenessTestRuntime( transportFactory: LivenessTransportFactory(router: LivenessHostRouter(), box: TransportBox()), - stackAccessTokenProvider: { throw TokenError() }, - stackAccessTokenForceRefresher: { throw TokenError() }, + stackAccessTokenProvider: { throw TestStackTokenError() }, + stackAccessTokenForceRefresher: { throw TestStackTokenError() }, now: { TestClock().now } ) let store = MobileShellComposite.preview(runtime: runtime) @@ -180,7 +179,7 @@ import Testing let runtime = LivenessTestRuntime( transportFactory: ConnectFailingTransportFactory(), stackAccessTokenProvider: { try provider.next() }, - stackAccessTokenForceRefresher: { throw FirstCallSucceedsTokenProvider.TokenError() }, + stackAccessTokenForceRefresher: { throw TestStackTokenError() }, now: { TestClock().now } ) let store = MobileShellComposite.preview(runtime: runtime) @@ -251,192 +250,3 @@ import Testing ) } } - -/// A Stack-token provider that succeeds exactly once, then fails — so the first -/// route's request builds auth (and then fails to connect) while a later route's -/// request fails its token build before any send. -final class FirstCallSucceedsTokenProvider: @unchecked Sendable { - struct TokenError: Error {} - private let lock = NSLock() - private var count = 0 - - func next() throws -> String { - let n: Int = lock.withLock { - count += 1 - return count - } - guard n == 1 else { throw TokenError() } - return "token-1" - } -} - -/// A transport whose `connect()` always fails, modeling an unreachable route. -actor ConnectFailingTransport: CmxByteTransport { - struct ConnectFailed: Error {} - - func connect() async throws { throw ConnectFailed() } - func receive() async throws -> Data? { nil } - func send(_ data: Data) async throws {} - func close() async {} -} - -struct ConnectFailingTransportFactory: CmxByteTransportFactory { - func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { - ConnectFailingTransport() - } -} - -/// A transport that answers any framed request with a successful -/// `mobile.attach_ticket.create` response carrying `ticket`, so the manual-host -/// pre-connect probe succeeds (and thereby reaches the Mac). -actor AttachTicketSuccessTransport: CmxByteTransport { - private let ticket: CmxAttachTicket - private var pendingFrames: [Data] = [] - private var receiveWaiters: [CheckedContinuation] = [] - private var isClosed = false - - init(ticket: CmxAttachTicket) { - self.ticket = ticket - } - - func connect() async throws {} - - func receive() async throws -> Data? { - if !pendingFrames.isEmpty { - return pendingFrames.removeFirst() - } - if isClosed { - return nil - } - return await withCheckedContinuation { continuation in - receiveWaiters.append(continuation) - } - } - - func send(_ data: Data) async throws { - var buffer = data - let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - for payload in payloads { - let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] - guard let id = parsed?["id"] as? String, - let ticketData = try? encoder.encode(ticket), - let ticketJSON = try? JSONSerialization.jsonObject(with: ticketData) else { continue } - let envelope: [String: Any] = ["id": id, "ok": true, "result": ["ticket": ticketJSON]] - guard let frame = try? MobileSyncFrameCodec.encodeFrame( - JSONSerialization.data(withJSONObject: envelope) - ) else { continue } - deliver(frame) - } - } - - func close() async { - isClosed = true - let waiters = receiveWaiters - receiveWaiters = [] - for waiter in waiters { - waiter.resume(returning: nil) - } - } - - private func deliver(_ frame: Data) { - if receiveWaiters.isEmpty { - pendingFrames.append(frame) - return - } - receiveWaiters.removeFirst().resume(returning: frame) - } -} - -struct AttachTicketSuccessTransportFactory: CmxByteTransportFactory { - let ticket: CmxAttachTicket - - func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { - AttachTicketSuccessTransport(ticket: ticket) - } -} - -/// Reports a fixed online/offline verdict and never emits a path change, for the -/// reachability preflight test. -struct StubReachability: ReachabilityProviding { - let online: Bool - var isOnline: Bool { get async { online } } - func pathChanges() -> AsyncStream { - AsyncStream { $0.finish() } - } -} - -/// A transport that answers every framed request with one configured RPC error -/// frame, so a pairing attempt fails at the authentication/trust gate without a -/// real host. Mirrors the receive/deliver pump of `LivenessTransport`. -actor ChecklistErrorTransport: CmxByteTransport { - private let code: String? - private let message: String - private var pendingFrames: [Data] = [] - private var receiveWaiters: [CheckedContinuation] = [] - private var isClosed = false - - init(code: String?, message: String) { - self.code = code - self.message = message - } - - func connect() async throws {} - - func receive() async throws -> Data? { - if !pendingFrames.isEmpty { - return pendingFrames.removeFirst() - } - if isClosed { - return nil - } - return await withCheckedContinuation { continuation in - receiveWaiters.append(continuation) - } - } - - func send(_ data: Data) async throws { - var buffer = data - let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) - for payload in payloads { - let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] - guard let id = parsed?["id"] as? String else { continue } - var error: [String: Any] = ["message": message] - if let code { - error["code"] = code - } - let envelope: [String: Any] = ["id": id, "ok": false, "error": error] - guard let frame = try? MobileSyncFrameCodec.encodeFrame( - JSONSerialization.data(withJSONObject: envelope) - ) else { continue } - deliver(frame) - } - } - - func close() async { - isClosed = true - let waiters = receiveWaiters - receiveWaiters = [] - for waiter in waiters { - waiter.resume(returning: nil) - } - } - - private func deliver(_ frame: Data) { - if receiveWaiters.isEmpty { - pendingFrames.append(frame) - return - } - receiveWaiters.removeFirst().resume(returning: frame) - } -} - -struct ChecklistErrorTransportFactory: CmxByteTransportFactory { - let code: String? - let message: String - - func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { - ChecklistErrorTransport(code: code, message: message) - } -} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/StubReachability.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/StubReachability.swift new file mode 100644 index 000000000000..55354126e95d --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/StubReachability.swift @@ -0,0 +1,10 @@ +import CmuxMobileTransport + +/// Reports a fixed online/offline verdict and never emits a path change. +struct StubReachability: ReachabilityProviding { + let online: Bool + var isOnline: Bool { get async { online } } + func pathChanges() -> AsyncStream { + AsyncStream { $0.finish() } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TestStackTokenError.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TestStackTokenError.swift new file mode 100644 index 000000000000..f2d62ae6e76b --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TestStackTokenError.swift @@ -0,0 +1 @@ +struct TestStackTokenError: Error {} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistRow.swift new file mode 100644 index 000000000000..08612deeb543 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistRow.swift @@ -0,0 +1,70 @@ +import CmuxMobileShellModel +import SwiftUI + +struct PairingChecklistRow: View { + let stage: MobilePairingStage + let status: MobilePairingStageStatus + + var body: some View { + HStack(alignment: .top, spacing: 12) { + statusIcon + .frame(width: 28, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text(stage.title) + .font(.body) + .foregroundStyle(.primary) + + if let message = status.failureMessage { + Text(message) + .font(.footnote) + .foregroundStyle(.primary) + if let guidance = status.failureGuidance { + Text(guidance) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + Text(stage.detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 0) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(stage.title) + .accessibilityIdentifier("MobilePairingChecklistRow.\(stage.accessibilityIdentifierSuffix)") + .accessibilityValue(status.accessibilityValue) + .accessibilityHint(accessibilityHint) + } + + @ViewBuilder + private var statusIcon: some View { + switch status { + case .inProgress: + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + default: + Image(systemName: status.symbolName) + .font(.title3) + .foregroundStyle(status.tintColor) + .accessibilityHidden(true) + } + } + + private var accessibilityHint: String { + switch (status.failureMessage, status.failureGuidance) { + case let (message?, guidance?): + return "\(message) \(guidance)" + case let (message?, nil): + return message + case let (nil, guidance?): + return guidance + case (nil, nil): + return stage.detail + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift index 636b0e991e00..d4826d36f00d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift @@ -1,5 +1,4 @@ import CmuxMobileShellModel -import CmuxMobileSupport import SwiftUI /// The network / authentication / trust pairing checklist: one resolving check @@ -19,71 +18,3 @@ struct PairingChecklistRows: View { } } } - -private struct PairingChecklistRow: View { - let stage: MobilePairingStage - let status: MobilePairingStageStatus - - var body: some View { - HStack(alignment: .top, spacing: 12) { - statusIcon - .frame(width: 28, alignment: .center) - - VStack(alignment: .leading, spacing: 2) { - Text(stage.title) - .font(.body) - .foregroundStyle(.primary) - - if let message = status.failureMessage { - Text(message) - .font(.footnote) - .foregroundStyle(.primary) - if let guidance = status.failureGuidance { - Text(guidance) - .font(.footnote) - .foregroundStyle(.secondary) - } - } else { - Text(stage.detail) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - Spacer(minLength: 0) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(stage.title) - .accessibilityIdentifier("MobilePairingChecklistRow.\(stage.accessibilityIdentifierSuffix)") - .accessibilityValue(status.accessibilityValue) - .accessibilityHint(accessibilityHint) - } - - @ViewBuilder - private var statusIcon: some View { - switch status { - case .inProgress: - ProgressView() - .controlSize(.small) - .accessibilityHidden(true) - default: - Image(systemName: status.symbolName) - .font(.title3) - .foregroundStyle(status.tintColor) - .accessibilityHidden(true) - } - } - - private var accessibilityHint: String { - switch (status.failureMessage, status.failureGuidance) { - case let (message?, guidance?): - return "\(message) \(guidance)" - case let (message?, nil): - return message - case let (nil, guidance?): - return guidance - case (nil, nil): - return stage.detail - } - } -} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingInputSignature.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingInputSignature.swift new file mode 100644 index 000000000000..744918f8bf00 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingInputSignature.swift @@ -0,0 +1,5 @@ +struct PairingInputSignature: Equatable { + let pairingCode: String + let host: String + let port: String +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift index df043a635dd9..13c627d32b56 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift @@ -404,9 +404,3 @@ private enum AddDeviceField: Hashable { case host case port } - -private struct PairingInputSignature: Equatable { - let pairingCode: String - let host: String - let port: String -}