Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
801e143
iOS pairing: surface network / auth / trust as individual check marks
austinywang Jun 14, 2026
7654046
Merge origin/main into issue-6084-ios-pairing-checkmarks
austinywang Jun 14, 2026
d2fea2c
Pairing checklist: clear the network gate only when the Mac was truly…
austinywang Jun 14, 2026
dc6ef4e
Merge origin/main: regenerate swift-file-length-budget.tsv
austinywang Jun 14, 2026
03f836a
Pairing checklist: only count the Mac reached after the transport con…
austinywang Jun 14, 2026
d4d2648
Pairing checklist: don't let a superseded attempt poison reached-Mac
austinywang Jun 14, 2026
5aa0e17
Pairing checklist: only foreground Add Device attempts publish it
austinywang Jun 14, 2026
dbb114b
Pairing checklist: count the manual attach-ticket probe as reaching t…
austinywang Jun 14, 2026
087f1a6
Pairing checklist model: one type per file + DocC on public members
austinywang Jun 14, 2026
90aae2f
Pairing checklist: a successful attach-ticket probe also marks the Ma…
austinywang Jun 14, 2026
d27db55
Pairing checklist: a superseding background attempt clears it
austinywang Jun 14, 2026
12d4cea
Merge remote-tracking branch 'origin/main' into issue-6084-ios-pairin…
austinywang Jun 14, 2026
9e10050
fix: address pairing checklist review feedback
austinywang Jun 14, 2026
43497c8
fix: regenerate Swift file length budget
austinywang Jun 14, 2026
8f9b08c
Merge remote-tracking branch 'origin/main' into issue-6084-ios-pairin…
austinywang Jun 14, 2026
7dab463
fix: regenerate Swift file length budget
austinywang Jun 14, 2026
f89a492
fix: clear pairing checklist on manual validation failures
austinywang Jun 14, 2026
ae8d0aa
Merge remote-tracking branch 'origin/main' into issue-6084-ios-pairin…
austinywang Jun 14, 2026
5ffa48a
Merge remote-tracking branch 'origin/main' into issue-6084-ios-pairin…
austinywang Jun 15, 2026
4139051
Merge remote-tracking branch 'origin/main' into issue-6084-ios-pairin…
austinywang Jun 15, 2026
fe269f2
Merge remote-tracking branch 'origin/main' into issue-6084-ios-pairin…
austinywang Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
6074 Sources/TextBoxInput.swift
5925 cmuxTests/TerminalAndGhosttyTests.swift
5526 cmuxTests/BrowserConfigTests.swift
5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
5238 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
4920 Sources/cmuxApp.swift
4467 Sources/Panels/FilePreviewPanel.swift
4400 cmuxTests/BrowserPanelTests.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
}
}

/// 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
}

/// Force a single Stack token refresh ahead of a retry.
///
/// The force-refresher closure maps a transient refresh failure (session
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ actor MobileCoreRPCSession {
private var cancelledQueuedRequestIDs: Set<String> = []
private var listeners: [UUID: EventListener] = [:]
private var isTearingDown: Bool = false
/// 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
/// `CmxNetworkByteTransport.sendAlreadyInProgress`. AsyncStream backed so
Expand All @@ -57,6 +63,13 @@ actor MobileCoreRPCSession {

func send(payload: Data, requestID: String) async throws -> Data {
_ = 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<Data, MobileShellConnectionError> = await withTaskCancellationHandler {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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 @@ -388,3 +389,101 @@ 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)
)
}
}
Loading
Loading