diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index e1aa8b7b14e8..efa8441605db 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -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 diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 20533bb0c13a..872531fbc80f 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -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 diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift index 393fb749c280..879ed94fe841 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift @@ -37,6 +37,12 @@ actor MobileCoreRPCSession { private var cancelledQueuedRequestIDs: Set = [] 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 @@ -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 = await withTaskCancellationHandler { 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/MobileCoreRPCClientTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift index 6d352e9aa6b6..88d4d6fa6344 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift +++ b/Packages/iOS/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/iOS/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift new file mode 100644 index 000000000000..8950b90a7801 --- /dev/null +++ b/Packages/iOS/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/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift new file mode 100644 index 000000000000..fc1251d71d28 --- /dev/null +++ b/Packages/iOS/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/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistResolution.swift new file mode 100644 index 000000000000..f221da749594 --- /dev/null +++ b/Packages/iOS/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, + .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) + ) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistState.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingChecklistState.swift new file mode 100644 index 000000000000..078b16259663 --- /dev/null +++ b/Packages/iOS/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/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairingFormat.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairingFormat.swift new file mode 100644 index 000000000000..7152e3693697 --- /dev/null +++ b/Packages/iOS/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/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index d36532176b2f..4d241b51d8d0 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -136,6 +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 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? @@ -511,6 +513,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// reading it again would report the first successful pair as `is_first_pair: /// false` and break the first-pair funnel. private var pairingAttemptIsFirstPair = false + private var pairingChecklistState = MobilePairingChecklistState() private var pendingPairingVersionWarningURL: String? /// The structured diagnostic log, injected from the app composition root. @@ -1161,8 +1164,22 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } public func connectManualHost(name: String, host: String, port: Int) async { + 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 { + pairingChecklistState.setForegroundAttempt(isForegroundPairing) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else { + pairingChecklistState.clearChecklist() connectionError = L10n.string("mobile.addDevice.invalidHost", defaultValue: "Enter a host or IP address, without spaces or URL paths.") connectionErrorGuidance = nil connectionState = .disconnected @@ -1177,6 +1194,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } guard (1...65535).contains(port) else { + pairingChecklistState.clearChecklist() connectionError = L10n.string("mobile.addDevice.invalidPort", defaultValue: "Enter a port from 1 to 65535.") connectionErrorGuidance = nil connectionState = .disconnected @@ -1317,7 +1335,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 } @@ -1795,7 +1813,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 @@ -1882,7 +1900,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` @@ -2190,6 +2208,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ rawValue: String? = nil, acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { + pairingChecklistState.setForegroundAttempt(true) let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) _ = beginPairingValidationAttempt() connectionAttemptGeneration = UUID() @@ -2474,16 +2493,28 @@ 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 { + if await client.didAttemptHostSend(), isCurrentConnectionAttempt(generation) { + pairingChecklistState.markReachedMac() + } + throw error + } + if isCurrentConnectionAttempt(generation) { + pairingChecklistState.markReachedMac() + } let response = try MobileManualAttachTicketCreateResponse.decode(resultData) return try response.ticket.constrainingRoutes(to: [route], fallbackDisplayName: displayName) } @@ -3373,7 +3404,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return nil } catch { lastError = error + let didReachHost = await client.didAttemptHostSend() guard isCurrentConnectionAttempt(generation) else { return nil } + if didReachHost { + 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)" ) @@ -3592,6 +3627,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private func beginPairingValidationAttempt(method: String? = nil) -> UUID { let attemptID = UUID() pairingAttemptID = attemptID + pairingChecklistState.beginValidationAttempt(hasMethod: method != nil) if let method { pairingAttemptStartedAt = runtime?.now() ?? Date() pairingAttemptMethod = method @@ -3614,6 +3650,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 } + pairingChecklistState.markConnected() var props: [String: AnalyticsValue] = [ "method": .string(method), "is_first_pair": .bool(pairingAttemptIsFirstPair), @@ -3666,6 +3703,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairingAttemptID = UUID() pairingAttemptStartedAt = nil pairingAttemptMethod = nil + pairingChecklistState.clearChecklist() } /// Apply a classified pairing failure to the user-visible error surface and @@ -3683,6 +3721,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionError = category.message } connectionErrorGuidance = category.guidance + pairingChecklistState.resolveFailure(category, hasInstrumentedAttempt: pairingAttemptMethod != nil) recordPairingFailed(reason: category.analyticsReason, phase: phase) } @@ -3767,6 +3806,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { applyPairingFailure(category ?? .unknown(host: nil, port: nil), phase: phase) return } + pairingChecklistState.resolveFailure( + category ?? .unknown(host: nil, port: nil), + hasInstrumentedAttempt: pairingAttemptMethod != nil + ) recordPairingFailed(reason: category?.analyticsReason ?? "other", phase: phase) } @@ -5398,6 +5441,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionState = .disconnected macConnectionStatus = .unavailable clearRemoteConnectionContext() + 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 @@ -5461,76 +5505,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 } 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/MobilePairingChecklistTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift new file mode 100644 index 000000000000..a7642ecb1e35 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift @@ -0,0 +1,194 @@ +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 (only when +/// the attempt reached the Mac) 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, + .emailMismatch(expected: "owner@example.com", actual: "other@example.com"), + .authFailed, + .ticketExpired, + .invalidCode, + .unrecognizedVersion, + .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, + .unrecognizedVersion, + .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 accountRouteAndEmailFailuresAreTrustStage() { + #expect(MobilePairingFailureCategory.accountMismatch.stage == .trust) + #expect(MobilePairingFailureCategory.unsupportedRoute.stage == .trust) + #expect(MobilePairingFailureCategory.emailMismatch(expected: "a@b.co", actual: "c@d.co").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) + #expect(!MobilePairingFailureCategory.emailMismatch(expected: "a@b.co", actual: "c@d.co").clearsPriorGates) + } + + // MARK: - Resolved checklist + + @Test func offlineFailsNetworkAndLeavesLaterGatesUntested() { + let category = MobilePairingFailureCategory.offline + let checklist = MobilePairingChecklist.resolving(category, reachedMac: false) + #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, reachedMac: true) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication == .failed(message: category.message, guidance: category.guidance)) + #expect(checklist.trust == .pending) + #expect(checklist.failedStage == .authentication) + } + + @Test func preNetworkAuthFailureLeavesNetworkUntested() { + // The ticket-identity preflight raises `authFailed` before reaching the + // Mac; with `reachedMac: false` the network gate must stay untested rather + // than show a false check mark. + let checklist = MobilePairingChecklist.resolving(.authFailed, reachedMac: false) + #expect(checklist.network == .pending) + #expect(checklist.authentication.isFailed) + #expect(checklist.trust == .pending) + } + + @Test func ticketExpiredFailsAuthenticationGate() { + let category = MobilePairingFailureCategory.ticketExpired + let checklist = MobilePairingChecklist.resolving(category, reachedMac: true) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication.isFailed) + #expect(checklist.trust == .pending) + } + + @Test func accountMismatchClearsNetworkAndAuthThenFailsTrust() { + let category = MobilePairingFailureCategory.accountMismatch + let checklist = MobilePairingChecklist.resolving(category, reachedMac: true) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication == .succeeded) + #expect(checklist.trust == .failed(message: category.message, guidance: category.guidance)) + #expect(checklist.failedStage == .trust) + } + + @Test func emailMismatchFailsTrustWithoutClaimingEarlierGates() { + // Caught client-side from the ticket before any connect, so the earlier + // gates stay untested even though trust is the failed gate. + let category = MobilePairingFailureCategory.emailMismatch(expected: "owner@example.com", actual: "other@example.com") + let checklist = MobilePairingChecklist.resolving(category, reachedMac: false) + #expect(checklist.network == .pending) + #expect(checklist.authentication == .pending) + #expect(checklist.trust == .failed(message: category.message, guidance: category.guidance)) + } + + @Test func untrustedRouteFailsTrustWithoutClaimingEarlierGates() { + // A route refused client-side never authenticates with the Mac, so even on + // a connect-phase failure (reachedMac) the earlier gates stay untested. + let category = MobilePairingFailureCategory.unsupportedRoute + let checklist = MobilePairingChecklist.resolving(category, reachedMac: true) + #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, reachedMac: false) + #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/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift new file mode 100644 index 000000000000..8ab4fc9ba659 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -0,0 +1,252 @@ +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 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 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 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()))) + #expect(result == .failed) + 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 result = await connectAcceptingVersionWarning(store, try attachURL(for: makeTicket(clock: TestClock()))) + #expect(result == .failed) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication == .succeeded) + #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 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 TestStackTokenError() }, + 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 + // (not falsely cleared) since no packet left the device (issue #6084). + let runtime = LivenessTestRuntime( + transportFactory: LivenessTransportFactory(router: LivenessHostRouter(), box: TransportBox()), + stackAccessTokenProvider: { throw TestStackTokenError() }, + stackAccessTokenForceRefresher: { throw TestStackTokenError() }, + 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 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 TestStackTokenError() }, + 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()), + now: { TestClock().now } + ) + let store = MobileShellComposite.preview(runtime: runtime) + store.signIn() + let result = await connectAcceptingVersionWarning(store, try attachURL(for: makeTicket(clock: TestClock()))) + #expect(result == .connected) + #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 + } + + /// Connect through the QR path, accepting the Mac/iPhone compatibility warning + /// if prompted (the scripted ticket carries no compatibility version, which the + /// host treats as a mismatch). Mirrors the user tapping "Continue anyway". + private func connectAcceptingVersionWarning( + _ store: MobileShellComposite, + _ url: String + ) async -> MobilePairingURLConnectionResult { + let result = await store.connectPairingURLResult(url) + 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) + ) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index 2c6c4a958cec..884187d95ad3 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -384,7 +384,13 @@ func makeConnectedStore( let store = MobileShellComposite.preview(runtime: runtime) store.signIn() let ticket = try makeTicket(clock: clock) - let connected = await store.connectPairingURL(try attachURL(for: ticket)) - #expect(connected, "scripted connect must succeed") + // The scripted ticket carries no Mac compatibility version, which the host now + // treats as skew and gates behind a "Continue anyway" approval; accept it so + // the connect proceeds, mirroring the user tapping through the warning. + var result = await store.connectPairingURLResult(try attachURL(for: ticket)) + if result == .needsUserApproval { + result = await store.acceptPairingVersionWarning() + } + #expect(result.didConnect, "scripted connect must succeed") return store } 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/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift new file mode 100644 index 000000000000..d45c42f0a3da --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift @@ -0,0 +1,63 @@ +import Foundation + +/// 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 +/// (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, + 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/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift new file mode 100644 index 000000000000..714f10bc1c22 --- /dev/null +++ b/Packages/iOS/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, 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. + 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/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStageStatus.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStageStatus.swift new file mode 100644 index 000000000000..0207aa934863 --- /dev/null +++ b/Packages/iOS/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 + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 6c7ff3a98512..44fa0801d8af 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -224,6 +224,7 @@ struct CMUXMobileRootView: View { pairingCode: $store.pairingCode, connectionError: store.connectionError, connectionErrorGuidance: store.connectionErrorGuidance, + pairingChecklist: store.pairingChecklist, versionWarning: store.pairingVersionWarning, connectPairingCode: { await store.connectPairingInput() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingStage+Display.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingStage+Display.swift new file mode 100644 index 000000000000..564cd6d17de7 --- /dev/null +++ b/Packages/iOS/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/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 new file mode 100644 index 000000000000..d4826d36f00d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift @@ -0,0 +1,20 @@ +import CmuxMobileShellModel +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)) + } + } +} 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 e82bc79cc097..13c627d32b56 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/iOS/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 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 let acceptVersionWarning: () async -> Void @@ -35,6 +40,9 @@ struct PairingView: View { @Environment(\.tailscaleStatusMonitor) private var tailscaleStatusMonitor @State private var validationError: String? @State private var isPairing = 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? @@ -173,6 +181,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) { @@ -264,15 +281,30 @@ struct PairingView: View { } } + /// Whether to show the network / authentication / trust checklist. Only for + /// 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 + && startedPairingInputSignature == currentPairingInputSignature + && 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 } @@ -310,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) @@ -344,6 +384,7 @@ struct PairingView: View { let taskID = UUID() pairingTaskID = taskID isPairing = true + startedPairingInputSignature = currentPairingInputSignature let task = Task { @MainActor in defer { if pairingTaskID == taskID { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3a0a9a02c87c..6da78c717c25 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -203199,6 +203199,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": "失敗" + } + } + } + }, "paneMemoryGuardrail.banner.title": { "extractionState": "manual", "localizations": { diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index a7fb7961b298..b411a8644c63 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -6545,6 +6545,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"