Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
public import CMUXMobileCore
internal import CmuxMobileRPC
internal import CmuxMobileShellModel
internal import CmuxMobileSupport
internal import CmuxMobileTransport
import Foundation
Expand Down Expand Up @@ -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)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -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)
}

Expand All @@ -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.
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading