Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
5abc51a
refactor: split mobile pairing checklist helpers
austinywang Jun 18, 2026
846e547
merge: resolve origin/main for iOS pairing checklist
austinywang Jun 18, 2026
173b8e4
fix: map unrecognized pairing URLs to network gate
austinywang Jun 18, 2026
65362b5
fix: address pairing checklist review findings
austinywang Jun 18, 2026
2af1c3e
merge: resolve origin/main
austinywang Jun 20, 2026
06977d0
refactor: split pairing checklist helper types
austinywang Jun 20, 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 @@ -19,7 +19,7 @@
6151 CLI/cmux_open.swift
6072 Sources/TextBoxInput.swift
5922 cmuxTests/TerminalAndGhosttyTests.swift
5566 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
5540 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
5526 cmuxTests/BrowserConfigTests.swift
4477 Sources/cmuxApp.swift
4467 Sources/Panels/FilePreviewPanel.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
@@ -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 {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
struct ConnectFailingTransportError: Error {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import CMUXMobileCore

struct ConnectFailingTransportFactory: CmxByteTransportFactory {
func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport {
ConnectFailingTransport()
}
}
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
@@ -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
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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,
.unrecognizedVersion, .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)
)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading