diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index abe5cbe6275..44a60b1ce90 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -20,7 +20,7 @@ 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5526 cmuxTests/BrowserConfigTests.swift -5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5238 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4920 Sources/cmuxApp.swift 4467 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 20533bb0c13..872531fbc80 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/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/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift index 393fb749c28..879ed94fe84 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift +++ b/Packages/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/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift index 6d352e9aa6b..88d4d6fa634 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift @@ -126,6 +126,35 @@ import Testing _ = try? await firstTask.value } + @Test func didAttemptHostSendStaysFalseWhenTransportNeverConnects() async throws { + // A route that fails to connect must not report a host send: pairing relies + // on this so an unreachable route never marks the network gate as reached + // (issue #6084). + let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 59222) + let runtime = TestMobileSyncRuntime(transportFactory: ConnectFailingTransportFactory()) + let ticket = try CmxAttachTicket( + workspaceID: "ws", + terminalID: "t", + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60), + authToken: "ticket-secret" + ) + let client = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + let request = try MobileCoreRPCClient.requestData(method: "workspace.list", id: "list") + await #expect(throws: (any Error).self) { + _ = try await client.sendRequest(request) + } + let reached = await client.didAttemptHostSend() + #expect(!reached, "a transport that never connects must not report a host send") + } + @Test func workspaceListResponseDecodesSnakeCaseWireShape() throws { let json = Data(""" { diff --git a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift index 778406808ea..6846224cd00 100644 --- a/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift +++ b/Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift @@ -43,6 +43,24 @@ struct TestMobileSyncRuntime: MobileSyncRuntime { struct MissingTestStackAccessToken: Error {} +/// A transport whose `connect()` always fails, modeling an unreachable route. +/// Used to prove the session never reports a host send when the channel never +/// came up (issue #6084). +actor ConnectFailingTransport: CmxByteTransport { + struct ConnectFailed: Error {} + + func connect() async throws { throw ConnectFailed() } + func receive() async throws -> Data? { nil } + func send(_ data: Data) async throws {} + func close() async {} +} + +struct ConnectFailingTransportFactory: CmxByteTransportFactory { + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + ConnectFailingTransport() + } +} + /// Async-safe one-shot boolean flag used to observe task progress in tests. actor AsyncFlag { private var value = false diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index d9c51354b40..75efc6751b2 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -1,5 +1,6 @@ public import CMUXMobileCore internal import CmuxMobileRPC +internal import CmuxMobileShellModel internal import CmuxMobileSupport internal import CmuxMobileTransport import Foundation @@ -388,3 +389,101 @@ extension MobilePairingFailureCategory { return String(format: L10n.string(key, defaultValue: defaultValue), host, port) } } + +extension MobilePairingFailureCategory { + /// Which of the three pairing gates (network / authentication / trust) this + /// failure belongs to, so the pairing checklist can mark the right check mark + /// red. `nil` only for ``cancelled`` (not a user-visible failure). + /// + /// The grouping is by gate, not by where in the code the failure is detected: + /// - **network** owns everything about establishing a usable transport to the + /// Mac, including the inputs that make that impossible before a packet is + /// sent (an invalid/loopback code, or no route this build can dial). These + /// all mean "this device could not reach a Mac". + /// - **authentication** owns the credential being rejected on the wire + /// (invalid/expired token or attach ticket). + /// - **trust** owns the security relationship: the Mac is a different account + /// (``accountMismatch``), the pairing code was minted for a different email + /// than this device (``emailMismatch``), or the route is not trusted to + /// carry the credential (``unsupportedRoute``). + var stage: MobilePairingStage? { + switch self { + case .offline, .hostUnreachable, .listenerNotRunning, .localNetworkBlocked, + .dnsFailed, .handshakeTimedOut, .connectionDropped, .invalidCode, + .loopbackRejected, .noSupportedRoute, .unknown: + return .network + case .authFailed, .ticketExpired: + return .authentication + case .accountMismatch, .emailMismatch, .unsupportedRoute: + return .trust + case .cancelled: + return nil + } + } + + /// Whether an *on-the-wire* occurrence of this failure proves every gate + /// before ``stage`` was already cleared. A rejection the Mac sends back proves + /// the device reached it (network cleared) and, for an account mismatch, that + /// the credential was read (authentication cleared). Transport failures, an + /// invalid code, a route refused client-side as untrusted (``unsupportedRoute``), + /// and the email/identity mismatch caught client-side from the ticket + /// (``emailMismatch``) prove nothing, so their earlier gates stay + /// ``MobilePairingStageStatus/pending`` (untested). + /// + /// This is only valid when the attempt actually reached the wire; the same + /// ``authFailed`` can be raised pre-network by the ticket-identity preflight, + /// where it has cleared nothing. ``MobilePairingChecklist/resolving(_:reachedMac:)`` + /// gates this with `reachedMac` so a pre-network rejection never shows a false + /// network check mark. + var clearsPriorGates: Bool { + switch self { + case .authFailed, .ticketExpired, .accountMismatch: + return true + default: + return false + } + } +} + +extension MobilePairingChecklist { + /// Build the resolved checklist for a failed attempt: the gate the failure + /// belongs to shows the headline + guidance, every gate the failure proves + /// was cleared shows a check mark, and every other gate stays untested. This + /// is the single projection from "why did pairing fail" to "which check marks + /// the user sees", so it is pure and unit-tested without a live connection. + /// + /// - Parameters: + /// - category: The classified failure. + /// - reachedMac: Whether the attempt actually got a request onto the + /// transport to the Mac, so a gate before the failed one that + /// ``MobilePairingFailureCategory/clearsPriorGates`` marks cleared really + /// was. A failure that never reached the transport — offline, a bad code, + /// or a local pre-send token/ticket failure — passes `false`, leaving the + /// earlier gates untested instead of falsely cleared. + static func resolving( + _ category: MobilePairingFailureCategory, + reachedMac: Bool + ) -> MobilePairingChecklist { + guard let failedStage = category.stage else { + // `.cancelled` is handled by the `catch is CancellationError` branches + // before classification, so this is only defensive: a cancelled + // attempt resolves nothing. + return MobilePairingChecklist(network: .pending, authentication: .pending, trust: .pending) + } + let failure = MobilePairingStageStatus.failed( + message: category.message, + guidance: category.guidance + ) + let priorCleared = reachedMac && category.clearsPriorGates + func status(for stage: MobilePairingStage) -> MobilePairingStageStatus { + if stage == failedStage { return failure } + if stage.order < failedStage.order { return priorCleared ? .succeeded : .pending } + return .pending + } + return MobilePairingChecklist( + network: status(for: .network), + authentication: status(for: .authentication), + trust: status(for: .trust) + ) + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index daa82110e79..093a46b54c3 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -136,6 +136,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// "Check that both devices are on the same Tailscale"). Set and cleared /// together with the error by the pairing-failure classifier sink. public private(set) var connectionErrorGuidance: String? + /// The per-gate status (network / authentication / trust) of the in-flight or + /// most recent pairing attempt, surfaced as individual check marks in + /// ``PairingView`` so the user can see exactly which stage succeeded or failed + /// (https://github.com/manaflow-ai/cmux/issues/6084). `nil` before any + /// attempt. Only foreground Add Device attempts publish it; background + /// reconnects, host switches, and device-tree taps clear or skip it so they + /// cannot repaint the foreground sheet with stale checklist state. + public private(set) var pairingChecklist: MobilePairingChecklist? /// A warning that must be accepted before pairing continues, currently used /// for Mac/iPhone app-version skew. public private(set) var pairingVersionWarning: String? @@ -454,6 +462,18 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// reading it again would report the first successful pair as `is_first_pair: /// false` and break the first-pair funnel. private var pairingAttemptIsFirstPair = false + /// Whether the in-flight attempt got a request onto the transport (proof it + /// reached the Mac), so the pairing checklist only marks the network gate + /// cleared for an auth/trust failure that the host actually returned — never + /// for a local pre-send token/ticket failure (issue #6084). Reset at each + /// attempt entry; set once `connect()` observes a client that attempted a send. + private var pairingAttemptReachedMac = false + /// Whether the in-flight attempt is a user-initiated pairing from the Add + /// Device flow (QR/link scan or the manual Pair button) rather than a + /// background reconnect, host switch, or device-tree tap. Only foreground + /// attempts publish ``pairingChecklist``, so a background reconnect can never + /// overwrite or render the foreground sheet's checklist (issue #6084). + private var isForegroundPairingAttempt = false private var pendingPairingVersionWarningURL: String? /// The structured diagnostic log, injected from the app composition root. @@ -640,6 +660,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.terminalInputText = "" self.connectionError = nil self.connectionErrorGuidance = nil + self.pairingChecklist = nil self.pairingVersionWarning = nil self.activeTicket = nil self.activeRoute = nil @@ -1093,8 +1114,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 { + isForegroundPairingAttempt = isForegroundPairing let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else { + clearPairingChecklist() connectionError = L10n.string("mobile.addDevice.invalidHost", defaultValue: "Enter a host or IP address, without spaces or URL paths.") connectionErrorGuidance = nil connectionState = .disconnected @@ -1109,6 +1144,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } guard (1...65535).contains(port) else { + clearPairingChecklist() connectionError = L10n.string("mobile.addDevice.invalidPort", defaultValue: "Enter a port from 1 to 65535.") connectionErrorGuidance = nil connectionState = .disconnected @@ -1249,7 +1285,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 } @@ -1727,7 +1763,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 @@ -1814,7 +1850,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` @@ -2122,6 +2158,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ rawValue: String? = nil, acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { + // QR/link pairing is a foreground Add Device attempt: it owns the checklist. + isForegroundPairingAttempt = true let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) _ = beginPairingValidationAttempt() connectionAttemptGeneration = UUID() @@ -2401,16 +2439,36 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ticket: probeTicket, allowsStackAuthFallback: true ) - let resultData = try await client.sendRequest( - MobileCoreRPCClient.requestData( - method: "mobile.attach_ticket.create", - params: [ - "ttl_seconds": 3600, - "scope": "mac", - ] - ), - timeoutNanoseconds: runtime.pairingRequestTimeoutNanoseconds - ) + let generation = connectionAttemptGeneration + let resultData: Data + do { + resultData = try await client.sendRequest( + MobileCoreRPCClient.requestData( + method: "mobile.attach_ticket.create", + params: [ + "ttl_seconds": 3600, + "scope": "mac", + ] + ), + timeoutNanoseconds: runtime.pairingRequestTimeoutNanoseconds + ) + } catch { + // This pre-connect probe reaches the Mac too. If it connected before + // being rejected, record that the Mac was reached so an auth/trust + // rejection here resolves the checklist with the network gate cleared + // (issue #6084). Re-check the generation after the await so a superseded + // attempt can't write the flag back. + if await client.didAttemptHostSend(), isCurrentConnectionAttempt(generation) { + pairingAttemptReachedMac = true + } + throw error + } + // A successful round trip also proves the Mac was reached, so a later local + // failure in `connect(ticket:)` (e.g. a pre-send token failure on the + // workspace-list request) still resolves with the network gate cleared. + if isCurrentConnectionAttempt(generation) { + pairingAttemptReachedMac = true + } let response = try MobileManualAttachTicketCreateResponse.decode(resultData) return try response.ticket.constrainingRoutes(to: [route], fallbackDisplayName: displayName) } @@ -2956,7 +3014,18 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return nil } catch { lastError = error + // Record whether this attempt got a request onto the transport, + // so the checklist can tell a host rejection (network reached) + // from a local pre-send token/ticket failure (issue #6084). + // Read the per-client signal first, then re-check generation + // before mutating shared state: this `await` can suspend, and a + // newer attempt may have reset `pairingAttemptReachedMac`, so a + // superseded attempt must not write it back. + let didReachHost = await client.didAttemptHostSend() guard isCurrentConnectionAttempt(generation) else { return nil } + if didReachHost { + pairingAttemptReachedMac = true + } 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)" ) @@ -3175,6 +3244,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private func beginPairingValidationAttempt(method: String? = nil) -> UUID { let attemptID = UUID() pairingAttemptID = attemptID + // A fresh attempt has not reached the Mac yet; cleared here (the shared + // funnel for every pairing/validation attempt) so a prior attempt's + // "reached" state can't leak into this one's checklist. + pairingAttemptReachedMac = false if let method { pairingAttemptStartedAt = runtime?.now() ?? Date() pairingAttemptMethod = method @@ -3186,9 +3259,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { "is_first_pair": .bool(pairingAttemptIsFirstPair), "attempt_id": .string(attemptID.uuidString), ]) + // The network gate is now being attempted; start a fresh checklist so + // a superseding attempt never inherits the prior attempt's check marks. + beginPairingChecklist() } else { pairingAttemptStartedAt = nil pairingAttemptMethod = nil + clearPairingChecklist() } return attemptID } @@ -3197,6 +3274,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// the attempt timing so a later state change can't double-fire. private func recordPairingSucceeded() { guard let method = pairingAttemptMethod else { return } + markPairingChecklistConnected() var props: [String: AnalyticsValue] = [ "method": .string(method), "is_first_pair": .bool(pairingAttemptIsFirstPair), @@ -3249,6 +3327,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairingAttemptID = UUID() pairingAttemptStartedAt = nil pairingAttemptMethod = nil + clearPairingChecklist() } /// Apply a classified pairing failure to the user-visible error surface and @@ -3266,6 +3345,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionError = category.message } connectionErrorGuidance = category.guidance + // Resolve before `recordPairingFailed` clears the attempt instrumentation + // (the checklist sink is gated on an in-flight attempt for the same reason + // the analytics emit is). + resolvePairingChecklist(category) recordPairingFailed(reason: category.analyticsReason, phase: phase) } @@ -3283,6 +3366,41 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionErrorGuidance = nil } + /// Reset the pairing checklist at the start of an instrumented attempt, so it + /// always reflects the current attempt (mirroring ``clearPairingError``). A + /// foreground Add Device attempt starts the network gate; any other attempt + /// (background reconnect, host switch, device-tree tap) clears it so a + /// superseded foreground attempt's stale spinner/result can't linger in the + /// Add Device sheet and hide the real connection error (issue #6084). + private func beginPairingChecklist() { + pairingChecklist = isForegroundPairingAttempt ? .connecting : nil + } + + /// Project a classified failure onto the per-gate checklist. Gated on a + /// foreground in-flight attempt (``isForegroundPairingAttempt`` + + /// ``pairingAttemptMethod``) so background reconnects, live-connection auth + /// evictions, and operational errors — which reuse the same classifier — never + /// repaint the pairing checklist. Uses ``pairingAttemptReachedMac`` (set only + /// once a request actually reached the transport) rather than the coarse phase + /// label, so a pre-send token/ticket failure never shows a cleared network gate + /// even though it surfaces in the connect/auth phase. + private func resolvePairingChecklist(_ category: MobilePairingFailureCategory) { + guard isForegroundPairingAttempt, pairingAttemptMethod != nil else { return } + pairingChecklist = .resolving(category, reachedMac: pairingAttemptReachedMac) + } + + /// Mark every gate cleared once a foreground attempt connects. + private func markPairingChecklistConnected() { + guard isForegroundPairingAttempt else { return } + pairingChecklist = .connected + } + + /// Drop the checklist on teardown (cancel, sign-out, switch, forget) so the + /// next ``PairingView`` starts clean. + private func clearPairingChecklist() { + pairingChecklist = nil + } + private func clearPairingVersionWarning() { pairingVersionWarning = nil pendingPairingVersionWarningURL = nil @@ -3350,6 +3468,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { applyPairingFailure(category ?? .unknown(host: nil, port: nil), phase: phase) return } + // `connect()` already set the headline (e.g. `noSupportedRoute`); keep the + // checklist in step with that message before the instrumentation clears. + resolvePairingChecklist(category ?? .unknown(host: nil, port: nil)) recordPairingFailed(reason: category?.analyticsReason ?? "other", phase: phase) } @@ -4945,6 +5066,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionState = .disconnected macConnectionStatus = .unavailable clearRemoteConnectionContext() + // Same in-flight-attempt gate as the analytics emit below: paints the + // failed gate (auth or trust) for a foreground pairing attempt, no-ops for + // a live-connection auth eviction. + resolvePairingChecklist(category) // Only emits while a pairing attempt is in flight: `recordPairingFailed` // no-ops once `pairingAttemptMethod` is nil (cleared on success and by // `invalidatePairingAttempt`), so live-connection auth failures that diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift new file mode 100644 index 00000000000..21fac074381 --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingChecklistTests.swift @@ -0,0 +1,192 @@ +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, + .loopbackRejected, + .unsupportedRoute, + .noSupportedRoute, + .unknown(host: "h", port: 1), + ] + for category in categories { + #expect(category.stage != nil, "category \(category) must map to a gate") + } + } + + @Test func cancelledHasNoStage() { + #expect(MobilePairingFailureCategory.cancelled.stage == nil) + } + + @Test func reachabilityFailuresAreNetworkStage() { + let networkCategories: [MobilePairingFailureCategory] = [ + .offline, + .hostUnreachable(host: "h", port: 1), + .listenerNotRunning(host: "h", port: 1), + .localNetworkBlocked, + .dnsFailed(host: "h", port: 1), + .handshakeTimedOut(host: "h", port: 1), + .connectionDropped(host: "h", port: 1), + .invalidCode, + .loopbackRejected, + .noSupportedRoute, + .unknown(host: "h", port: 1), + ] + for category in networkCategories { + #expect(category.stage == .network, "\(category) should be a network-gate failure") + } + } + + @Test func credentialFailuresAreAuthenticationStage() { + #expect(MobilePairingFailureCategory.authFailed.stage == .authentication) + #expect(MobilePairingFailureCategory.ticketExpired.stage == .authentication) + } + + @Test func 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/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift new file mode 100644 index 00000000000..4942b816316 --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeChecklistTests.swift @@ -0,0 +1,442 @@ +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 FirstCallSucceedsTokenProvider.TokenError() }, + now: { TestClock().now } + ) + let store = MobileShellComposite.preview(runtime: runtime) + store.signIn() + await store.connectManualHost(name: "Work Mac", host: "127.0.0.1", port: 58_465) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .succeeded) + #expect(checklist.authentication.isFailed) + } + + @Test func preSendTokenFailureLeavesNetworkGateUntested() async throws { + // The Stack token provider fails, so the request never reaches the + // transport. The auth gate fails, but the network gate must stay untested + // (not falsely cleared) since no packet left the device (issue #6084). + struct TokenError: Error {} + let runtime = LivenessTestRuntime( + transportFactory: LivenessTransportFactory(router: LivenessHostRouter(), box: TransportBox()), + stackAccessTokenProvider: { throw TokenError() }, + stackAccessTokenForceRefresher: { throw TokenError() }, + now: { TestClock().now } + ) + let store = MobileShellComposite.preview(runtime: runtime) + store.signIn() + let result = await connectAcceptingVersionWarning(store, try attachURL(for: makeTicket(clock: TestClock()))) + #expect(result == .failed) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .pending) + #expect(checklist.authentication.isFailed) + #expect(checklist.trust == .pending) + } + + @Test func unreachableRouteThenPreSendAuthFailureLeavesNetworkUntested() async throws { + // Multi-route attempt: the first route fails to connect, then a later + // request fails its Stack-token build before any send. The network gate + // must stay untested — an unreachable route must not leave a sticky + // "reached" that greens the network gate (issue #6084 autoreview follow-up). + let provider = FirstCallSucceedsTokenProvider() + let runtime = LivenessTestRuntime( + transportFactory: ConnectFailingTransportFactory(), + stackAccessTokenProvider: { try provider.next() }, + stackAccessTokenForceRefresher: { throw FirstCallSucceedsTokenProvider.TokenError() }, + now: { TestClock().now } + ) + let store = MobileShellComposite.preview(runtime: runtime) + store.signIn() + let result = await connectAcceptingVersionWarning(store, try attachURL(for: makeTwoRouteTicket())) + #expect(result == .failed) + let checklist = try #require(store.pairingChecklist) + #expect(checklist.network == .pending) + #expect(checklist.authentication.isFailed) + #expect(checklist.trust == .pending) + } + + @Test func successfulPairingClearsEveryGate() async throws { + let runtime = LivenessTestRuntime( + transportFactory: LivenessTransportFactory(router: LivenessHostRouter(), box: TransportBox()), + 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) + ) + } +} + +/// A Stack-token provider that succeeds exactly once, then fails — so the first +/// route's request builds auth (and then fails to connect) while a later route's +/// request fails its token build before any send. +final class FirstCallSucceedsTokenProvider: @unchecked Sendable { + struct TokenError: Error {} + private let lock = NSLock() + private var count = 0 + + func next() throws -> String { + let n: Int = lock.withLock { + count += 1 + return count + } + guard n == 1 else { throw TokenError() } + return "token-1" + } +} + +/// A transport whose `connect()` always fails, modeling an unreachable route. +actor ConnectFailingTransport: CmxByteTransport { + struct ConnectFailed: Error {} + + func connect() async throws { throw ConnectFailed() } + func receive() async throws -> Data? { nil } + func send(_ data: Data) async throws {} + func close() async {} +} + +struct ConnectFailingTransportFactory: CmxByteTransportFactory { + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + ConnectFailingTransport() + } +} + +/// A transport that answers any framed request with a successful +/// `mobile.attach_ticket.create` response carrying `ticket`, so the manual-host +/// pre-connect probe succeeds (and thereby reaches the Mac). +actor AttachTicketSuccessTransport: CmxByteTransport { + private let ticket: CmxAttachTicket + private var pendingFrames: [Data] = [] + private var receiveWaiters: [CheckedContinuation] = [] + private var isClosed = false + + init(ticket: CmxAttachTicket) { + self.ticket = ticket + } + + func connect() async throws {} + + func receive() async throws -> Data? { + if !pendingFrames.isEmpty { + return pendingFrames.removeFirst() + } + if isClosed { + return nil + } + return await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func send(_ data: Data) async throws { + var buffer = data + let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + for payload in payloads { + let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] + guard let id = parsed?["id"] as? String, + let ticketData = try? encoder.encode(ticket), + let ticketJSON = try? JSONSerialization.jsonObject(with: ticketData) else { continue } + let envelope: [String: Any] = ["id": id, "ok": true, "result": ["ticket": ticketJSON]] + guard let frame = try? MobileSyncFrameCodec.encodeFrame( + JSONSerialization.data(withJSONObject: envelope) + ) else { continue } + deliver(frame) + } + } + + func close() async { + isClosed = true + let waiters = receiveWaiters + receiveWaiters = [] + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + private func deliver(_ frame: Data) { + if receiveWaiters.isEmpty { + pendingFrames.append(frame) + return + } + receiveWaiters.removeFirst().resume(returning: frame) + } +} + +struct AttachTicketSuccessTransportFactory: CmxByteTransportFactory { + let ticket: CmxAttachTicket + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + AttachTicketSuccessTransport(ticket: ticket) + } +} + +/// Reports a fixed online/offline verdict and never emits a path change, for the +/// reachability preflight test. +struct StubReachability: ReachabilityProviding { + let online: Bool + var isOnline: Bool { get async { online } } + func pathChanges() -> AsyncStream { + AsyncStream { $0.finish() } + } +} + +/// A transport that answers every framed request with one configured RPC error +/// frame, so a pairing attempt fails at the authentication/trust gate without a +/// real host. Mirrors the receive/deliver pump of `LivenessTransport`. +actor ChecklistErrorTransport: CmxByteTransport { + private let code: String? + private let message: String + private var pendingFrames: [Data] = [] + private var receiveWaiters: [CheckedContinuation] = [] + private var isClosed = false + + init(code: String?, message: String) { + self.code = code + self.message = message + } + + func connect() async throws {} + + func receive() async throws -> Data? { + if !pendingFrames.isEmpty { + return pendingFrames.removeFirst() + } + if isClosed { + return nil + } + return await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func send(_ data: Data) async throws { + var buffer = data + let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) + for payload in payloads { + let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] + guard let id = parsed?["id"] as? String else { continue } + var error: [String: Any] = ["message": message] + if let code { + error["code"] = code + } + let envelope: [String: Any] = ["id": id, "ok": false, "error": error] + guard let frame = try? MobileSyncFrameCodec.encodeFrame( + JSONSerialization.data(withJSONObject: envelope) + ) else { continue } + deliver(frame) + } + } + + func close() async { + isClosed = true + let waiters = receiveWaiters + receiveWaiters = [] + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + private func deliver(_ frame: Data) { + if receiveWaiters.isEmpty { + pendingFrames.append(frame) + return + } + receiveWaiters.removeFirst().resume(returning: frame) + } +} + +struct ChecklistErrorTransportFactory: CmxByteTransportFactory { + let code: String? + let message: String + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + ChecklistErrorTransport(code: code, message: message) + } +} diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index 2c6c4a958ce..884187d95ad 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/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/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingChecklist.swift new file mode 100644 index 00000000000..d45c42f0a3d --- /dev/null +++ b/Packages/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/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift new file mode 100644 index 00000000000..4ff645d8274 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStage.swift @@ -0,0 +1,27 @@ +import Foundation + +/// One of the three discrete gates a pairing attempt must clear, in the order +/// they are attempted. Surfacing each as its own check mark lets the user tell +/// exactly which stage succeeded or failed instead of reading one opaque +/// "could not connect" (https://github.com/manaflow-ai/cmux/issues/6084). +public enum MobilePairingStage: Equatable, Sendable, CaseIterable { + /// Reaching the Mac over the network: reachability, routing, the listener, + /// and opening the transport to the address the pairing code points at. The + /// first gate — nothing else can be attempted until it clears. + case network + /// Verifying this device's signed-in account credential with the Mac. + case authentication + /// Confirming the Mac belongs to the same cmux account, over a route trusted + /// to carry that credential. The last gate. + case trust + + /// Position in the attempt order, used to decide which gates an earlier + /// failure leaves untested (`.pending`) versus provably cleared. + public var order: Int { + switch self { + case .network: return 0 + case .authentication: return 1 + case .trust: return 2 + } + } +} diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStageStatus.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStageStatus.swift new file mode 100644 index 00000000000..0207aa93486 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingStageStatus.swift @@ -0,0 +1,33 @@ +import Foundation + +/// The resolution state of a single pairing gate, mirrored into an individual +/// check mark in the pairing UI (https://github.com/manaflow-ai/cmux/issues/6084). +public enum MobilePairingStageStatus: Equatable, Sendable { + /// Not started, or left untested because an earlier gate has not cleared. + case pending + /// Currently being attempted. + case inProgress + /// Cleared. + case succeeded + /// Failed, carrying the localized headline and optional actionable guidance + /// the UI shows beneath this gate's row. + case failed(message: String, guidance: String?) + + /// Whether this gate is the one that failed. + public var isFailed: Bool { + if case .failed = self { return true } + return false + } + + /// The failure headline, when this gate failed. + public var failureMessage: String? { + if case let .failed(message, _) = self { return message } + return nil + } + + /// The actionable next-step line, when this gate failed and one applies. + public var failureGuidance: String? { + if case let .failed(_, guidance) = self { return guidance } + return nil + } +} diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 39a04e3b8e6..4467ea6d894 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -214,6 +214,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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingStage+Display.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingStage+Display.swift new file mode 100644 index 00000000000..564cd6d17de --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingStage+Display.swift @@ -0,0 +1,79 @@ +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Display-only derivations for the pairing checklist: the localized title and +/// one-line "what this gate checks" detail for each gate, and the icon / tint / +/// accessibility wording for each gate status. Kept out of the model so the +/// model stays UI-agnostic and these strings live next to the view that shows +/// them (https://github.com/manaflow-ai/cmux/issues/6084). +extension MobilePairingStage { + var title: String { + switch self { + case .network: + return L10n.string("mobile.pairing.checklist.network.title", defaultValue: "Network") + case .authentication: + return L10n.string("mobile.pairing.checklist.authentication.title", defaultValue: "Authentication") + case .trust: + return L10n.string("mobile.pairing.checklist.trust.title", defaultValue: "Trust") + } + } + + /// The neutral one-line description shown while the gate is pending, in + /// progress, or cleared (a failure replaces it with the actionable message). + var detail: String { + switch self { + case .network: + return L10n.string("mobile.pairing.checklist.network.detail", defaultValue: "Reaching your Mac") + case .authentication: + return L10n.string("mobile.pairing.checklist.authentication.detail", defaultValue: "Verifying your account") + case .trust: + return L10n.string("mobile.pairing.checklist.trust.detail", defaultValue: "Confirming it's your Mac") + } + } + + /// Stable suffix for the row's accessibility identifier, so UI tests can + /// target a specific gate. + var accessibilityIdentifierSuffix: String { + switch self { + case .network: return "network" + case .authentication: return "authentication" + case .trust: return "trust" + } + } +} + +extension MobilePairingStageStatus { + var symbolName: String { + switch self { + case .pending: return "circle" + case .inProgress: return "circle.dotted" + case .succeeded: return "checkmark.circle.fill" + case .failed: return "xmark.circle.fill" + } + } + + var tintColor: Color { + switch self { + case .pending: return .secondary + case .inProgress: return .blue + case .succeeded: return .green + case .failed: return .red + } + } + + /// A localized status word appended to the gate's VoiceOver label, so the + /// status is announced even though it is conveyed visually by icon + color. + var accessibilityValue: String { + switch self { + case .pending: + return L10n.string("mobile.pairing.checklist.status.pending", defaultValue: "Not started") + case .inProgress: + return L10n.string("mobile.pairing.checklist.status.inProgress", defaultValue: "In progress") + case .succeeded: + return L10n.string("mobile.pairing.checklist.status.succeeded", defaultValue: "Succeeded") + case .failed: + return L10n.string("mobile.pairing.checklist.status.failed", defaultValue: "Failed") + } + } +} diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift new file mode 100644 index 00000000000..636b0e991e0 --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingChecklistView.swift @@ -0,0 +1,89 @@ +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// The network / authentication / trust pairing checklist: one resolving check +/// mark per gate so the user can see exactly which stage of pairing succeeded or +/// failed, instead of one opaque "could not connect" +/// (https://github.com/manaflow-ai/cmux/issues/6084). +/// +/// A pure value view — it takes the immutable ``MobilePairingChecklist`` snapshot +/// and renders it, holding no store reference, so it is safe to embed in the +/// pairing form. +struct PairingChecklistRows: View { + let checklist: MobilePairingChecklist + + var body: some View { + ForEach(MobilePairingStage.allCases, id: \.self) { stage in + PairingChecklistRow(stage: stage, status: checklist.status(for: stage)) + } + } +} + +private struct PairingChecklistRow: View { + let stage: MobilePairingStage + let status: MobilePairingStageStatus + + var body: some View { + HStack(alignment: .top, spacing: 12) { + statusIcon + .frame(width: 28, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text(stage.title) + .font(.body) + .foregroundStyle(.primary) + + if let message = status.failureMessage { + Text(message) + .font(.footnote) + .foregroundStyle(.primary) + if let guidance = status.failureGuidance { + Text(guidance) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + Text(stage.detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 0) + } + .accessibilityElement(children: .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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift index e3188b3be93..3ee1661e908 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift @@ -18,6 +18,11 @@ struct PairingView: View { /// (for example "Check that both devices are on the same Tailscale"). `nil` /// when the headline is already the full instruction. let connectionErrorGuidance: String? + /// Per-gate (network / authentication / trust) status of the current pairing + /// attempt, shown as individual check marks. Rendered only after the user + /// starts a pairing attempt from this screen 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 { @@ -363,3 +404,9 @@ private enum AddDeviceField: Hashable { case host case port } + +private struct PairingInputSignature: Equatable { + let pairingCode: String + let host: String + let port: String +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cae4cd19836..47175b3961e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -201279,6 +201279,193 @@ } } } + }, + "mobile.pairing.checklist.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続状況" + } + } + } + }, + "mobile.pairing.checklist.network.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + } + } + }, + "mobile.pairing.checklist.network.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reaching your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac への接続" + } + } + } + }, + "mobile.pairing.checklist.authentication.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証" + } + } + } + }, + "mobile.pairing.checklist.authentication.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verifying your account" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントの確認" + } + } + } + }, + "mobile.pairing.checklist.trust.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trust" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "信頼" + } + } + } + }, + "mobile.pairing.checklist.trust.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirming it's your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あなたの Mac であることを確認" + } + } + } + }, + "mobile.pairing.checklist.status.pending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not started" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未開始" + } + } + } + }, + "mobile.pairing.checklist.status.inProgress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In progress" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中" + } + } + } + }, + "mobile.pairing.checklist.status.succeeded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Succeeded" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + } + } + }, + "mobile.pairing.checklist.status.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + } + } } } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 1bd9d332abf..7642c8fccb6 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -6239,6 +6239,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"