diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index c65b87036c83..ab21cc941e3f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -332,7 +332,7 @@ extension MobilePairingFailureCategory { case .unsupportedRoute: return L10n.string( "mobile.pairing.secureRouteRequired", - defaultValue: "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer." + defaultValue: "This pairing route is not trusted. Enter the Mac's numeric Tailscale IP and port, or scan its pairing QR." ) case .noSupportedRoute: return L10n.string( @@ -373,7 +373,7 @@ extension MobilePairingFailureCategory { case .tailscaleUnavailable: return L10n.string( "mobile.pairing.guidance.tailscaleUnavailable", - defaultValue: "Open Tailscale on both devices, confirm they use the same network, then scan a fresh Pair iPhone code from the Mac." + defaultValue: "Open Tailscale on both devices, then scan a fresh Mac pairing QR or enter its numeric Tailscale IP and port." ) case .hostUnreachable, .dnsFailed, .handshakeTimedOut: return L10n.string( @@ -416,7 +416,7 @@ extension MobilePairingFailureCategory { case .ticketExpired, .unsupportedRoute, .noSupportedRoute: return L10n.string( "mobile.pairing.guidance.rescanFresh", - defaultValue: "On cmux 0.64.17, open Pair iPhone. On newer versions, open Tailscale Pairing. Then scan a fresh QR or link." + defaultValue: "Open Tailscale Pairing on the Mac and scan a fresh QR, or enter the Mac's numeric Tailscale IP and port." ) case .unrecognizedVersion: return L10n.string( diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift index 6df1c123e0ef..33bac0e4e87f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift @@ -7,6 +7,14 @@ import Foundation @MainActor extension MobileShellComposite { + /// Manual tickets are local placeholders, not authenticated Mac + /// identities. The real device id is learned from the host-status + /// response after the exact route has authenticated. + nonisolated static func isSyntheticManualDeviceID(_ rawValue: String) -> Bool { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + return value == "manual-ticket-request" || value.hasPrefix("manual-") + } + nonisolated static func boundedPairingRequestTimeoutNanoseconds( runtime: any MobileSyncRuntime, attemptStartedAt: Date @@ -49,6 +57,9 @@ extension MobileShellComposite { ) async throws -> CmxAttachTicket { let directRoute = try Self.manualHostRoute(host: host, port: port) let displayName = name.isEmpty ? host : name + // Non-loopback callers supply an exact user-entry capability to the + // subsequent `connect` call. This helper intentionally mints only a + // route-scoped synthetic ticket and never broadens bearer authority. if MobileShellRouteAuthPolicy.routeAllowsStackAuth(directRoute) { do { let ticket = try await requestManualAttachTicket( diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 3673d658357d..c586354fed51 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2561,18 +2561,99 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } - let directRoute = try? Self.manualHostRoute( + guard let directRoute = try? Self.manualHostRoute( host: normalizedHost, port: port - ) - let sameRouteProbeClient: MobileCoreRPCClient? = directRoute.flatMap { route in + ) else { + if recordsPairingAttempt { + recordAppEvent(.pairingStarted) + recordAppEvent(.pairingFailed, failure: .protocolViolation) + } + connectionError = L10n.string( + "mobile.addDevice.invalidHost", + defaultValue: "Enter a host or IP address, without spaces or URL paths." + ) + connectionErrorGuidance = nil + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + analytics.capture("ios_pairing_failed", [ + "method": .string("manual"), + "reason": .string("invalid_host"), + "failure_phase": .string("validation"), + "is_first_pair": .bool(!hasKnownPairedMac), + ]) + return + } + + let isLoopbackRoute = MobileShellRouteAuthPolicy.routeIsLoopback(directRoute) + if MobileShellRouteAuthPolicy.ticketRejectsLoopbackRoutes( + [directRoute], + isPhysicalDevice: Self.isPhysicalDevice + ) { + if recordsPairingAttempt { + recordAppEvent(.pairingStarted) + recordAppEvent(.pairingFailed, failure: .unsupportedRoute) + } + connectionError = L10n.string( + "mobile.pairing.loopbackRejected", + defaultValue: "This device cannot connect to the Mac through localhost. Scan the Mac's Tailscale pairing QR or enter its numeric Tailscale IP." + ) + connectionErrorGuidance = nil + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + analytics.capture("ios_pairing_failed", [ + "method": .string("manual"), + "reason": .string("loopback_rejected"), + "failure_phase": .string("validation"), + "is_first_pair": .bool(!hasKnownPairedMac), + ]) + return + } + + // A fresh manual attempt is an explicit user action, so a numeric + // Tailscale address can receive the same exact-destination capability + // as a scanned/pasted QR route. MagicDNS, LAN, and arbitrary names do + // not provide a stable peer proof and must fail before any TCP dial. + let userTailscalePairingAuthorization: CmxUserTailscalePairingAuthorization? + if recordsPairingAttempt && !isLoopbackRoute { + userTailscalePairingAuthorization = try? CmxUserTailscalePairingAuthorization( + host: normalizedHost, + port: port + ) + guard userTailscalePairingAuthorization != nil else { + recordAppEvent(.pairingStarted) + recordAppEvent(.pairingFailed, failure: .unsupportedRoute) + connectionError = L10n.string( + "mobile.addDevice.tailscaleNumericRequired", + defaultValue: "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported." + ) + connectionErrorGuidance = nil + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + analytics.capture("ios_pairing_failed", [ + "method": .string("manual"), + "reason": .string("unsupported_route"), + "failure_phase": .string("validation"), + "is_first_pair": .bool(!hasKnownPairedMac), + ]) + return + } + } else { + userTailscalePairingAuthorization = nil + } + + let sameRouteProbeClient: MobileCoreRPCClient? = { + let route = directRoute guard remoteClient?.sharesPhysicalTransportRoute( with: route ) == true else { return nil } return remoteClient - } + }() if sameRouteProbeClient == nil { activeRoute = directRoute } @@ -2590,7 +2671,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } // Fast offline preflight: fail immediately instead of stacking // per-route timeouts into the opaque ~60s blob. - let manualRoutes = directRoute.map { [$0] } ?? [] + let manualRoutes = [directRoute] if sameRouteProbeClient == nil { guard await failPairingIfOffline( attemptID: attemptID, @@ -2618,7 +2699,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } let noThrowFailure = try await connect( ticket: ticket, - allowsStackAuthFallback: true, + // The generic Stack-bearer fallback remains loopback-only. + // Numeric Tailscale pairing uses the explicit authorization + // mode below, which is independent of this fallback flag. + allowsStackAuthFallback: isLoopbackRoute, + userTailscalePairingAuthorizations: userTailscalePairingAuthorization.map { [$0] } ?? [], pairedMacDeviceID: pairedMacDeviceID, instanceTagExpectation: instanceTagExpectation, ifStillCurrent: ifStillCurrent @@ -9231,7 +9316,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let ticketMacDeviceID = ticket.macDeviceID .trimmingCharacters(in: .whitespacesAndNewlines) let requestedMacDeviceID = pairedMacDeviceID - ?? (ticketMacDeviceID.isEmpty ? nil : ticketMacDeviceID) + ?? (ticketMacDeviceID.isEmpty + || Self.isSyntheticManualDeviceID(ticketMacDeviceID) + ? nil + : ticketMacDeviceID) let previousForegroundKeyBeforeConnect = foregroundOrRecoveryMacKey let currentFocusedConnection: MacConnection? = foregroundMacDeviceID.flatMap { macID in @@ -9260,10 +9348,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // pins no addresses (automatic, or a legacy pairing without an Iroh // identity). let directOnlyDialCandidates = directOnlyDialCandidates - ?? irohMethodPinnedDialCandidates( - forMacDeviceID: requestedMacDeviceID ?? ticket.macDeviceID, - instanceTag: instanceTagExpectation.expectedTag - ) + ?? (userTailscalePairingAuthorizations.isEmpty + ? irohMethodPinnedDialCandidates( + forMacDeviceID: requestedMacDeviceID ?? ticket.macDeviceID, + instanceTag: instanceTagExpectation.expectedTag + ) + : nil) let supportedRoutes = supportedRoutes( for: ticket, supportedKinds: supportedKinds, @@ -9652,7 +9742,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } let ticketDeviceID = ticket.macDeviceID .trimmingCharacters(in: .whitespacesAndNewlines) - let expectedDeviceID = pairedMacDeviceID ?? (ticketDeviceID.isEmpty ? nil : ticketDeviceID) + let expectedDeviceID = pairedMacDeviceID + ?? (ticketDeviceID.isEmpty + || Self.isSyntheticManualDeviceID(ticketDeviceID) + ? nil + : ticketDeviceID) if await adoptWouldConflictWithStoredInstanceAuthority( expectation: instanceTagExpectation, reportedInstanceTag: reportedInstanceTag, @@ -10020,6 +10114,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { supportedKinds.contains(route.kind) } } + // An explicit QR/manual entry is itself the authorization event. Keep + // the dial on the exact numeric Tailscale destination it named even + // when the app-wide method is Automatic, Iroh, or Tailscale Only. + // `directOnly` is reserved for an already-paired Direct connection and + // must remain the stronger, Iroh-only constraint. + if !directOnly, !userTailscalePairingAuthorizations.isEmpty { + return supportedRoutes.filter { route in + Self.userTailscalePairingAuthorization( + for: route, + authorizations: userTailscalePairingAuthorizations + ) != nil + } + } + // The explicit Tailscale method is strict: only authorized Tailscale // destinations may be dialed, and an unavailable route leaves the app // disconnected instead of silently switching to Iroh. The method is @@ -10135,7 +10243,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ ticket: CmxAttachTicket, adoptingReportedDeviceID reportedDeviceID: String? ) -> CmxAttachTicket { - guard ticket.macDeviceID.isEmpty, + guard (ticket.macDeviceID.isEmpty + || Self.isSyntheticManualDeviceID(ticket.macDeviceID)), let reportedDeviceID = reportedDeviceID? .trimmingCharacters(in: .whitespacesAndNewlines), !reportedDeviceID.isEmpty, diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift new file mode 100644 index 000000000000..0b26e5559045 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift @@ -0,0 +1,223 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileRPC +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileShell + +/// End-to-end shell coverage for the compatibility Tailscale pairing funnel. +/// +/// These tests intentionally drive the same `connectPairingInput` and +/// `connectManualHost` entry points used by the scanner/paste and Add Computer +/// UI. The scripted host records the transport request and every bearer so a +/// route can be proven to be both selected and authorized before the fix lands. +@MainActor +@Suite struct TailscalePairingRegressionTests { + private nonisolated static let fixedNow = Date(timeIntervalSince1970: 1_700_000_000) + private let host = "100.71.210.41" + private let port = CmxMobileDefaults.defaultHostPort + + @Test(arguments: MobileConnectionMethod.allCases) + func currentQRCodeEnteredThroughSharedInputAuthorizesExactRoute( + _ method: MobileConnectionMethod + ) async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Self.fixedNow }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let store = makeStore(runtime: runtime, connectionMethod: method) + store.pairingCode = currentQRCode() + + await store.connectPairingInput() + + #expect(store.connectionState == MobileConnectionState.connected) + #expect(store.activeRoute?.kind == .tailscale) + #expect(factory.attemptedAuthorizationModes() == [ + .userAuthorizedTailscalePairing( + try CmxUserTailscalePairingAuthorization(host: host, port: port) + ), + ]) + let requests = await router.authorization(for: "workspace.list") + #expect(requests.first?.stackAccessToken == "test-stack-token") + } + + @Test func legacyTokenlessQRCodeEnteredThroughPasteUsesTheSameAuthorization() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Self.fixedNow }, + supportedRouteKinds: [.tailscale] + ) + let store = makeStore(runtime: runtime) + // Older Macs encode the same route in the v1 full-key ticket. The + // payload is tokenless, so the explicit in-app paste is the authority. + let ticket = try CmxAttachTicket( + workspaceID: "", + terminalID: nil, + // Legacy pairing URLs may carry no trusted device id; the host + // status response supplies the identity after the authenticated + // route is established. + macDeviceID: "", + macDisplayName: "Legacy Mac", + macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion, + routes: [try tailscaleRoute()], + expiresAt: Self.fixedNow.addingTimeInterval(3600), + authToken: nil + ) + store.pairingCode = try attachURL(for: ticket) + + await store.connectPairingInput() + + #expect(store.connectionState == MobileConnectionState.connected) + #expect(store.activeRoute?.endpoint == .hostPort(host: host, port: port)) + #expect(factory.attemptedAuthorizationModes() == [ + .userAuthorizedTailscalePairing( + try CmxUserTailscalePairingAuthorization(host: host, port: port) + ), + ]) + } + + @Test func manualNumericEntryAuthorizesExactDestination() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let pairedMacStore = try MobilePairedMacStore( + databaseURL: directory.appendingPathComponent("paired-macs.sqlite3") + ) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Self.fixedNow }, + supportedRouteKinds: [.tailscale], + supportsServerPushEvents: false + ) + let store = makeStore(runtime: runtime, pairedMacStore: pairedMacStore) + + await store.connectManualHost(name: "Work Mac", host: host, port: port) + + #expect(store.connectionState == MobileConnectionState.connected) + #expect(store.activeRoute?.kind == .tailscale) + #expect(factory.attemptedAuthorizationModes() == [ + .userAuthorizedTailscalePairing( + try CmxUserTailscalePairingAuthorization(host: host, port: port) + ), + ]) + #expect((await router.authorization(for: "workspace.list")).first?.stackAccessToken == "test-stack-token") + let saved = try await pairedMacStore.activeMac(stackUserID: "phone-user") + #expect(saved?.legacyTailscaleRoutes?.first?.endpoint == .hostPort(host: host, port: port)) + } + + @Test func manualMagicDNSHasDeterministicSafeFallbackWithoutDialing() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Self.fixedNow }, + supportedRouteKinds: [.tailscale] + ) + let store = makeStore(runtime: runtime) + + await store.connectManualHost( + name: "Work Mac", + host: "work-mac.tailnet.ts.net", + port: port + ) + + #expect(store.connectionState == MobileConnectionState.disconnected) + #expect(store.activeRoute == nil) + #expect(factory.attemptedAuthorizationModes().isEmpty) + #expect(store.connectionError?.localizedCaseInsensitiveContains("numeric") == true) + #expect(await router.count(of: "workspace.list") == 0) + } + + @Test func arbitraryAndLanManualHostsNeverReceiveAStackBearer() async throws { + for host in ["192.168.1.77", "10.0.0.5", "example.com"] { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Self.fixedNow }, + supportedRouteKinds: [.tailscale] + ) + let store = makeStore(runtime: runtime) + + await store.connectManualHost(name: "Untrusted", host: host, port: port) + + #expect(store.connectionState == MobileConnectionState.disconnected) + #expect(factory.attemptedAuthorizationModes().isEmpty) + #expect(await router.authorization(for: "workspace.list").isEmpty) + } + } + + @Test func externallyOpenedQRCodeDoesNotMintInAppTailscaleAuthorization() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Self.fixedNow }, + supportedRouteKinds: [.tailscale] + ) + let store = makeStore(runtime: runtime) + + let result = await store.connectPairingURLResult(currentQRCode()) + + #expect(result == .failed) + #expect(factory.attemptedAuthorizationModes().isEmpty) + #expect(await router.authorization(for: "workspace.list").isEmpty) + } + + private func makeStore( + runtime: any MobileSyncRuntime, + pairedMacStore: (any MobilePairedMacStoring)? = nil, + connectionMethod: MobileConnectionMethod? = nil + ) -> MobileShellComposite { + let methodStore: MobileConnectionMethodStore? = connectionMethod.map { method in + let defaults = UserDefaults( + suiteName: "tailscale-pairing-regression-method-\(UUID().uuidString)" + )! + let store = MobileConnectionMethodStore(defaults: defaults) + store.method = method + return store + } + return MobileShellComposite( + runtime: runtime, + isSignedIn: true, + pairedMacStore: pairedMacStore, + connectionMethodStore: methodStore, + identityProvider: StaticIdentityProvider(userID: "phone-user"), + reachability: AlwaysOnlineReachability(), + pairingHintDefaults: UserDefaults( + suiteName: "tailscale-pairing-regression-\(UUID().uuidString)" + )! + ) + } + + private func currentQRCode() -> String { + "cmux-ios://attach?v=2&pc=1&r=\(host):\(port)" + } + + private func tailscaleRoute() throws -> CmxAttachRoute { + try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: host, port: port), + priority: 10 + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift index 614a36cc328e..ad3a3b2f1b9e 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift @@ -53,9 +53,12 @@ public struct MobileShellRouteAuthPolicy { return host } - /// Maps a manually typed host to the transport kind that should be used. + /// Maps a manually typed host to the transport kind used for route checks. /// - Parameter host: The host to classify. /// - Returns: `.debugLoopback` for loopback hosts, otherwise `.tailscale`. + /// + /// Non-loopback classification is not authorization: callers must still + /// attach an exact numeric Tailscale pairing capability before dialing. public static func manualRouteKind(for host: String) -> CmxAttachTransportKind { let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if isLoopbackHost(normalizedHost) { @@ -140,9 +143,11 @@ public struct MobileShellRouteAuthPolicy { return isLoopbackHost(host) } - /// Whether a manual host should warn that it cannot carry account credentials. + /// Whether a manual host should show the explicit non-loopback trust guidance. /// - Parameter host: The manually typed host. - /// - Returns: `true` for every valid host outside loopback. + /// - Returns: `true` for every valid host outside loopback, where a + /// numeric Tailscale capability (or another supported secure path) is + /// required before account credentials may be sent. public static func manualHostNeedsTrustWarning(_ host: String) -> Bool { guard let normalizedHost = normalizedManualNetworkHost(host) else { return false diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAutoConnectMigrationExplanation.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAutoConnectMigrationExplanation.swift index e114bc88c1b1..47b102dd9249 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAutoConnectMigrationExplanation.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAutoConnectMigrationExplanation.swift @@ -59,7 +59,7 @@ struct MobileAutoConnectMigrationExplanation: View { private var guidance: some View { Text(L10n.string( "mobile.autoConnectMigration.guidance", - defaultValue: "cmux 0.64.17 still works over Tailscale. Choose Connect iPhone/iPad on that Mac and scan its Pair iPhone code once." + defaultValue: "Older cmux versions still work over Tailscale. Open Tailscale Pairing on the Mac and scan its QR, or enter its numeric Tailscale IP and port once." )) .font(.body) .foregroundStyle(.secondary) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift index 31147cf47350..e54750f92afc 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift @@ -222,9 +222,8 @@ extension MobilePairingScannerSheet { L10n.string( "mobile.tailscalePairing.instructions", defaultValue: """ - Install Tailscale on both devices and use the same Tailscale network. On cmux 0.64.17, \ - choose Connect iPhone/iPad and scan the Pair iPhone code. On newer versions, open \ - Tailscale Pairing and scan its code here. + Install Tailscale on both devices and use the same Tailscale network. Open Tailscale \ + Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port. """ ) } @@ -234,10 +233,9 @@ extension MobilePairingScannerSheet { L10n.string( "mobile.tailscalePairing.emptyDescription", defaultValue: """ - Install Tailscale on both devices and use the same Tailscale network. On cmux 0.64.17, \ - choose Connect iPhone/iPad and scan the Pair iPhone code. On newer versions, open \ - Tailscale Pairing and scan its code here. To use Auto-Connect instead, open Settings, \ - tap Connection Method, and choose Auto-Connect. + Install Tailscale on both devices and use the same Tailscale network. Open Tailscale \ + Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port. \ + To use Auto-Connect instead, open Settings, tap Connection Method, and choose Auto-Connect. """ ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift index 6d6030989a54..8bac0eb24da8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift @@ -79,7 +79,7 @@ struct PairingView: View { .accessibilityIdentifier("MobileAddDeviceNameField") TextField( - L10n.string("mobile.addDevice.hostPlaceholder", defaultValue: "127.0.0.1 (simulator only)"), + L10n.string("mobile.addDevice.hostPlaceholder", defaultValue: "100.x.x.x (Tailscale IP; 127.0.0.1 in Simulator)"), text: $host ) .focused($focusedField, equals: .host) @@ -102,7 +102,7 @@ struct PairingView: View { Text(MobilePairingScannerSheet.guidanceText) Text(L10n.string( "mobile.addDevice.help", - defaultValue: "Manual host and port entry is an advanced fallback for reconnecting an already paired Mac." + defaultValue: "Scan the Mac's pairing QR, or enter its numeric Tailscale IP and port. In the Simulator, 127.0.0.1 can connect to a local Mac. MagicDNS names and local or LAN hosts aren't supported for account-authenticated pairing." )) } } @@ -136,7 +136,7 @@ struct PairingView: View { .textSelection(.enabled) .accessibilityIdentifier("MobileAddDeviceSignedInAccount") - Text(L10n.string("mobile.addDevice.accountHelp", defaultValue: "Manual pairing uses this account. If it does not match the Mac, scan a QR/link from the Mac.")) + Text(L10n.string("mobile.addDevice.accountHelp", defaultValue: "Pairing uses this account. If it does not match the Mac, sign in to the same account, then scan the Mac QR or enter its numeric Tailscale IP.")) .font(.footnote) .foregroundStyle(.secondary) } @@ -376,7 +376,7 @@ struct PairingView: View { } return L10n.string( "mobile.addDevice.manualRouteWarning", - defaultValue: "Manual credentials work only in the simulator. On a device, choose Tailscale and scan the Mac QR." + defaultValue: "For account-authenticated pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported." ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift index aa6112bf4d4b..1a5f4b577dff 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift @@ -40,8 +40,8 @@ struct SetupHelpGateContent { The computer then appears on this phone automatically. \ To pair through Tailscale, install Tailscale on both devices \ and connect them to the same Tailscale network. \ - On cmux 0.64.17, choose Connect iPhone/iPad and scan its Pair iPhone code. \ - On newer versions, open Tailscale Pairing and scan its code here. + Open Tailscale Pairing on the Mac and scan its QR here, or enter the Mac's \ + numeric Tailscale IP and port. """ ), link: nil, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index fd6d5998da96..223a10524081 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -145446,16 +145446,124 @@ "mobile.pairing.manual.title": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يمكنك المسح؟ أدخل عنوان IP الرقمي لـ Tailscale لهذا الـ Mac والمنفذ:" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ne možete skenirati? Unesite numeričku Tailscale IP adresu i port ovog Maca:" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kan du ikke scanne? Indtast denne Macs numeriske Tailscale-IP-adresse og port:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst nicht scannen? Gib die numerische Tailscale-IP-Adresse und den Port dieses Mac ein:" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Can't scan? Add this Mac manually:" + "value": "Can't scan? Enter this Mac's numeric Tailscale IP and port:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿No puedes escanear? Introduce la dirección IP numérica de Tailscale y el puerto de este Mac:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de scanner ? Saisissez l’adresse IP Tailscale numérique et le port de ce Mac :" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non riesci a scansionare? Inserisci l’indirizzo IP numerico Tailscale e la porta di questo Mac:" } }, "ja": { "stringUnit": { "state": "translated", - "value": "スキャンできない場合は、この Mac を手動で追加します:" + "value": "スキャンできない場合は、この Mac の数値 Tailscale IP アドレスとポートを入力してください:" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ស្កេនមិនបានមែនទេ? បញ្ចូល IP Tailscale ជាលេខ និងច្រករបស់ Mac នេះ៖" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔할 수 없나요? 이 Mac의 숫자 Tailscale IP와 포트를 입력하세요:" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kan du ikke skanne? Skriv inn det numeriske Tailscale-IP-et og porten til denne Mac-en:" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie możesz zeskanować? Wpisz numeryczny adres IP Tailscale i port tego Maca:" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não consegue escanear? Digite o IP numérico do Tailscale e a porta deste Mac:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удаётся отсканировать? Введите числовой IP-адрес Tailscale и порт этого Mac:" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สแกนไม่ได้ใช่ไหม ป้อน IP Tailscale แบบตัวเลขและพอร์ตของ Mac เครื่องนี้:" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayamıyor musunuz? Bu Mac'in sayısal Tailscale IP adresini ve bağlantı noktasını girin:" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдається відсканувати? Введіть числову IP-адресу Tailscale і порт цього Mac:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法扫描?请输入这台 Mac 的数字 Tailscale IP 和端口:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法掃描?請輸入這部 Mac 的數字 Tailscale IP 與連接埠:" } } } diff --git a/Sources/Mobile/Pairing/MobilePairingView.swift b/Sources/Mobile/Pairing/MobilePairingView.swift index 2ef757a26fe4..8f7b50b1658a 100644 --- a/Sources/Mobile/Pairing/MobilePairingView.swift +++ b/Sources/Mobile/Pairing/MobilePairingView.swift @@ -6,26 +6,14 @@ import SwiftUI /// The macOS window for pairing an iPhone with this Mac. /// -/// A transport chooser leads the page: Iroh pairing is automatic for -/// signed-in iPhones and needs no QR, while the Tailscale tab shows the QR -/// used when the iPhone's connection method is explicitly set to Tailscale. -/// Each tab ends in a status row that doubles as the debugging surface -/// (live transport state, manual-entry routes, signed-in account). +/// The page presents one pairing artifact: a Tailscale QR for signed-in +/// iPhones that use the explicit Tailscale connection method. Iroh remains an +/// automatic, no-QR discovery path and is shown only as status information. struct MobilePairingView: View { - /// The transport whose pairing flow the window presents. - enum TransportChoice: Hashable { - case iroh - case tailscale - } - @State private var model = MobilePairingModel() @State private var signInModel = AccountSignInModel( flow: AppDelegate.shared?.auth?.accountFlow ) - /// The user's explicit tab pick. `nil` until they touch the chooser; the - /// effective tab then follows Iroh readiness (Iroh when ready, else - /// Tailscale) so the page opens on the transport that will work. - @State private var chosenTransport: TransportChoice? /// The manual-entry value that was just copied (the host or the port /// string), so only the matching button shows the brief "Copied" flash. /// The two values can never collide: one is a host, the other a port. @@ -98,29 +86,6 @@ struct MobilePairingView: View { } } - // MARK: Transport chooser - - private func effectiveTransport(reachableViaIroh: Bool) -> TransportChoice { - chosenTransport ?? (reachableViaIroh ? .iroh : .tailscale) - } - - private func transportPicker(reachableViaIroh: Bool) -> some View { - Picker( - String(localized: "mobile.pairing.transportPicker", defaultValue: "Connection"), - selection: Binding( - get: { effectiveTransport(reachableViaIroh: reachableViaIroh) }, - set: { chosenTransport = $0 } - ) - ) { - // Transport product names are literal tokens, not translatable copy. - Text(verbatim: "Iroh").tag(TransportChoice.iroh) - Text(verbatim: "Tailscale").tag(TransportChoice.tailscale) - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(maxWidth: 280) - } - /// The App Store-badge-styled button for getting cmux on the iPhone. private var getIPhoneAppBadge: some View { Link(destination: Self.iphoneAppURL) { @@ -215,27 +180,19 @@ struct MobilePairingView: View { @ViewBuilder private func readyContent(_ ready: MobilePairingModel.Ready) -> some View { - let transport = effectiveTransport(reachableViaIroh: ready.reachableViaIroh) - VStack(alignment: .center, spacing: 14) { - transportPicker(reachableViaIroh: ready.reachableViaIroh) getIPhoneAppBadge - if transport == .tailscale { - tailscaleReadyBody(ready) - } else { - irohBody(waiting: ready.reachableViaIroh) - } + tailscaleReadyBody(ready) } .frame(maxWidth: .infinity) Divider() - if transport == .tailscale { - tailscaleRow(ready) - manualEntry(ready) - } else { + tailscaleRow(ready) + if ready.reachableViaIroh { irohRow(reachableViaIroh: ready.reachableViaIroh) } + manualEntry(ready) footer } @@ -274,30 +231,6 @@ struct MobilePairingView: View { } } - @ViewBuilder - private func irohBody(waiting: Bool) -> some View { - Text(String( - localized: "mobile.pairing.irohInstruction", - defaultValue: "Install cmux on your iPhone and sign in with the same account. It connects automatically — no code needed." - )) - .cmuxFont(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: 420) - - if waiting { - waitingIndicator - } - - // The selected iOS app matters beyond the QR: it addresses the - // paired-Mac records Iroh discovery hands to that exact app, so the - // picker stays available on the automatic path too. - if model.availableIOSAppTargets.count > 1 { - pairingTargetPicker - } - } - private var waitingIndicator: some View { HStack(spacing: 6) { ProgressView().controlSize(.small) @@ -350,22 +283,25 @@ struct MobilePairingView: View { @ViewBuilder private func needsReachableTransportContent(reachableViaIroh: Bool) -> some View { - let transport = effectiveTransport(reachableViaIroh: reachableViaIroh) - VStack(alignment: .center, spacing: 14) { - transportPicker(reachableViaIroh: reachableViaIroh) getIPhoneAppBadge - if transport == .iroh { - irohBody(waiting: reachableViaIroh) - } else { - tailscaleMissingBody + tailscaleMissingBody + if reachableViaIroh { + Text(String( + localized: "mobile.pairing.irohInstruction", + defaultValue: "Install cmux on your iPhone and sign in with the same account. It connects automatically — no code needed." + )) + .cmuxFont(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity) Divider() - if transport == .iroh { + if reachableViaIroh { irohRow(reachableViaIroh: reachableViaIroh) } @@ -466,7 +402,7 @@ struct MobilePairingView: View { @ViewBuilder private func manualEntry(_ ready: MobilePairingModel.Ready) -> some View { VStack(alignment: .leading, spacing: 6) { - Text(String(localized: "mobile.pairing.manual.title", defaultValue: "Can't scan? Add this Mac manually:")) + Text(String(localized: "mobile.pairing.manual.title", defaultValue: "Can't scan? Enter this Mac's numeric Tailscale IP and port:")) .cmuxFont(.caption, weight: .semibold) .foregroundStyle(.secondary) ForEach(ready.tailscaleLines, id: \.self) { line in diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 4ebfb5c2af4f..ad048ac58642 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -466,13 +466,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Manual pairing uses this account. If it does not match the Mac, scan a QR/link from the Mac." + "value": "Pairing uses this account. If it does not match the Mac, sign in to the same account, then scan the Mac QR or enter its numeric Tailscale IP." } }, "ja": { "stringUnit": { "state": "translated", - "value": "手動ペアリングではこのアカウントを使用します。Mac と一致しない場合は、Mac から QR/リンクをスキャンしてください。" + "value": "ペアリングではこのアカウントを使用します。Mac と一致しない場合は、同じアカウントでサインインしてから、Mac の QR をスキャンするか数値の Tailscale IP を入力してください。" } } } @@ -500,13 +500,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Manual host and port entry is an advanced fallback for reconnecting an already paired Mac." + "value": "Scan the Mac's pairing QR, or enter its numeric Tailscale IP and port. In the Simulator, 127.0.0.1 can connect to a local Mac. MagicDNS names and local or LAN hosts aren't supported for account-authenticated pairing." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ホストとポートの手入力は、ペアリング済みのMacへ再接続するための高度な代替手段です。" + "value": "Mac のペアリング QR をスキャンするか、数値の Tailscale IP アドレスとポートを入力してください。シミュレータでは 127.0.0.1 で Mac に接続できます。アカウント認証によるペアリングでは、MagicDNS 名やローカル/LAN ホストはサポートされません。" } } } @@ -534,13 +534,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "127.0.0.1 (simulator only)" + "value": "100.x.x.x (Tailscale IP; 127.0.0.1 in Simulator)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "127.0.0.1(シミュレータのみ)" + "value": "100.x.x.x(Tailscale IP、シミュレータでは127.0.0.1)" } } } @@ -579,19 +579,36 @@ } } }, + "mobile.addDevice.tailscaleNumericRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale でペアリングするには、Mac の数値 Tailscale IP アドレスを入力するか QR をスキャンしてください。MagicDNS 名やローカル/LAN ホストはサポートされません。" + } + } + } + }, "mobile.addDevice.manualRouteWarning": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Manual credentials work only in the simulator. On a device, choose Tailscale and scan the Mac QR." + "value": "For account-authenticated pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported." } }, "ja": { "stringUnit": { "state": "translated", - "value": "アカウント認証情報はシミュレータのループバックでのみ送信できます。実機では Tailscale を選択し、Mac の QR をスキャンしてください。" + "value": "アカウント認証によるペアリングでは、Mac の数値 Tailscale IP アドレスを入力するか QR をスキャンしてください。MagicDNS 名やローカル/LAN ホストはサポートされません。" } } } @@ -5107,13 +5124,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "cmux 0.64.17 still works over Tailscale. Choose Connect iPhone/iPad on that Mac and scan its Pair iPhone code once." + "value": "Older cmux versions still work over Tailscale. Open Tailscale Pairing on the Mac and scan its QR, or enter its numeric Tailscale IP and port once." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux 0.64.17でもTailscale経由で接続できます。Macで「Connect iPhone/iPad」を選び、「Pair iPhone」のコードを一度スキャンしてください。" + "value": "古い cmux バージョンでも Tailscale 経由で接続できます。Mac で「Tailscale Pairing」を開き、QR をスキャンするか、数値の Tailscale IP アドレスとポートを一度入力してください。" } } } @@ -6433,13 +6450,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "On cmux 0.64.17, open Pair iPhone. On newer versions, open Tailscale Pairing. Then scan a fresh QR or link." + "value": "Open Tailscale Pairing on the Mac and scan a fresh QR, or enter the Mac's numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux 0.64.17では「Pair iPhone」を開きます。新しいバージョンでは「Tailscale Pairing」を開き、新しいQRコードまたはリンクをスキャンしてください。" + "value": "Mac で「Tailscale Pairing」を開き、新しい QR をスキャンするか、Mac の数値 Tailscale IP アドレスとポートを入力してください。" } } } @@ -6450,13 +6467,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Open Tailscale on both devices, confirm they use the same network, then scan a fresh Pair iPhone code from the Mac." + "value": "Open Tailscale on both devices, then scan a fresh Mac pairing QR or enter its numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方のデバイスでTailscaleを開き、同じネットワークを使用していることを確認してから、Macの新しい「Pair iPhone」コードをスキャンしてください。" + "value": "両方のデバイスで Tailscale を開き、新しい Mac のペアリング QR をスキャンするか、数値の Tailscale IP アドレスとポートを入力してください。" } } } @@ -6580,6 +6597,57 @@ } } }, + "mobile.pairing.manual.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't scan? Enter this Mac's numeric Tailscale IP and port:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンできない場合は、この Mac の数値 Tailscale IP アドレスとポートを入力してください:" + } + } + } + }, + "mobile.pairing.manual.copyIP": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy IP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP をコピー" + } + } + } + }, + "mobile.pairing.manual.copyPort": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Port" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポートをコピー" + } + } + } + }, "mobile.pairing.navigationTitle": { "extractionState": "manual", "localizations": { @@ -6722,13 +6790,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "This pairing route is not trusted. Pair again with a fresh QR/link from that computer." + "value": "This pairing route is not trusted. Enter the Mac's numeric Tailscale IP and port, or scan its pairing QR." } }, "ja": { "stringUnit": { "state": "translated", - "value": "このペアリング経路は信頼できません。そのコンピュータの新しい QR/リンクで再度ペアリングしてください。" + "value": "このペアリング経路は信頼できません。Mac の数値 Tailscale IP アドレスとポートを入力するか、ペアリング QR をスキャンしてください。" } } } @@ -8745,13 +8813,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Install cmux on your computer, sign in to the same account, and leave it running. The computer then appears on this phone automatically. To pair through Tailscale, install Tailscale on both devices and connect them to the same Tailscale network. On cmux 0.64.17, choose Connect iPhone/iPad and scan its Pair iPhone code. On newer versions, open Tailscale Pairing and scan its code here." + "value": "Install cmux on your computer, sign in to the same account, and leave it running. The computer then appears on this phone automatically. To pair through Tailscale, install Tailscale on both devices and connect them to the same Tailscale network. Open Tailscale Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "コンピュータにcmuxをインストールし、同じcmuxアカウントでサインインしたまま起動しておきます。コンピュータは自動的にこの端末に表示されます。Tailscale経由でペアリングするには、両方のデバイスにTailscaleをインストールし、同じTailscaleネットワークに接続してください。cmux 0.64.17では「Connect iPhone/iPad」を選び、「Pair iPhone」のコードをスキャンします。新しいバージョンでは「Tailscale Pairing」を開き、表示されたコードをここでスキャンします。" + "value": "コンピュータに cmux をインストールし、同じ cmux アカウントでサインインしたまま起動しておきます。コンピュータは自動的にこの端末に表示されます。Tailscale 経由でペアリングするには、両方のデバイスに Tailscale をインストールし、同じ Tailscale ネットワークに接続してください。Mac で「Tailscale Pairing」を開き、QR をここでスキャンするか、Mac の数値 Tailscale IP アドレスとポートを入力します。" } } } @@ -9612,13 +9680,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "QR pairing usually needs both devices on the same Tailscale network. Turn Tailscale on first, or pair by host and port on a trusted local network." + "value": "Keep both devices on the same Tailscale network. Scan the Mac's pairing QR, or enter its numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "QR ペアリングには通常、両方のデバイスが同じ Tailscale ネットワークに接続されている必要があります。まず Tailscale をオンにするか、信頼できるローカルネットワーク上でホストとポートを指定してペアリングしてください。" + "value": "両方のデバイスを同じ Tailscale ネットワークに接続してください。Mac のペアリング QR をスキャンするか、数値の Tailscale IP アドレスとポートを入力します。" } } } @@ -20407,13 +20475,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Install Tailscale on both devices and use the same Tailscale network. On cmux 0.64.17, choose Connect iPhone/iPad and scan the Pair iPhone code. On newer versions, open Tailscale Pairing and scan its code here. To use Iroh instead, open Settings, tap Connection Method, and choose Iroh." + "value": "Install Tailscale on both devices and use the same Tailscale network. Open Tailscale Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port. To use Iroh instead, open Settings, tap Connection Method, and choose Iroh." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方のデバイスにTailscaleをインストールし、同じTailscaleネットワークを使用してください。cmux 0.64.17では「Connect iPhone/iPad」を選び、「Pair iPhone」のコードをスキャンします。新しいバージョンでは「Tailscale Pairing」を開き、表示されたコードをここでスキャンします。代わりにIrohを使うには、「設定」を開き、「接続方法」をタップして「Iroh」を選択してください。" + "value": "両方のデバイスに Tailscale をインストールし、同じ Tailscale ネットワークを使用してください。Mac で「Tailscale Pairing」を開き、QR をここでスキャンするか、Mac の数値 Tailscale IP アドレスとポートを入力します。Iroh を使うには、「設定」で「接続方法」を開き、「Iroh」を選択してください。" } } } @@ -20424,13 +20492,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Install Tailscale on both devices and use the same Tailscale network. On cmux 0.64.17, choose Connect iPhone/iPad and scan the Pair iPhone code. On newer versions, open Tailscale Pairing and scan its code here." + "value": "Install Tailscale on both devices and use the same Tailscale network. Open Tailscale Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方のデバイスにTailscaleをインストールし、同じTailscaleネットワークを使用してください。cmux 0.64.17では「Connect iPhone/iPad」を選び、「Pair iPhone」のコードをスキャンします。新しいバージョンでは「Tailscale Pairing」を開き、表示されたコードをここでスキャンします。" + "value": "両方のデバイスに Tailscale をインストールし、同じ Tailscale ネットワークを使用してください。Mac で「Tailscale Pairing」を開き、QR をここでスキャンするか、Mac の数値 Tailscale IP アドレスとポートを入力してください。" } } } @@ -23684,4 +23752,4 @@ } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 81397d0f9ba2..785fc66649d5 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -634,7 +634,7 @@ final class TerminalOutputCollector { } @MainActor -@Test func manualHostPairingRejectsTailscaleMagicDNSWithoutSendingAuth() async throws { +@Test func manualHostPairingRejectsTailscaleMagicDNSWithNumericGuidance() async throws { let responses = ScriptedTransportResponses([]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], @@ -648,6 +648,7 @@ final class TerminalOutputCollector { #expect(store.phase == .pairing) #expect(store.connectionState == .disconnected) #expect(store.activeRoute == nil) + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") #expect(try await responses.sentRequests().isEmpty) } @@ -671,7 +672,7 @@ final class TerminalOutputCollector { #expect(store.connectionState == .disconnected) #expect(store.activeTicket == nil) #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") #expect(try await responses.sentRequests().isEmpty) } @@ -695,51 +696,51 @@ final class TerminalOutputCollector { #expect(store.connectionState == .disconnected) #expect(store.activeTicket == nil) #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") #expect(try await responses.sentRequests().isEmpty) } @MainActor -@Test func manualHostPairingRejectsTailscaleBeforeLegacyProbeOrFallback() async throws { - let responses = ScriptedTransportResponses([]) +@Test func manualHostPairingAuthorizesExactNumericTailscaleDestination() async throws { + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "manual-workspace", title: "Work Workspace"), + try rpcHostStatusFrame(renderGrid: false), + ]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], transportFactory: ScriptedTransportFactory(responses: responses), - stackAccessToken: "stack-token-for-fallback" + stackAccessToken: "test-stack-token" ) let store = CMUXMobileShellStore.preview(runtime: runtime) store.signIn() await store.connectManualHost(name: "Work Mac", host: "100.71.210.41", port: 15432) - #expect(store.phase == .pairing) - #expect(store.connectionState == .disconnected) - #expect(store.activeRoute == nil) - #expect(try await responses.sentRequests().isEmpty) + #expect(store.phase == .workspaces) + #expect(store.connectionState == .connected) + #expect(store.activeRoute?.kind == .tailscale) + let requests = try await responses.sentRequests() + #expect(requests.first?.method == "workspace.list") + #expect(requests.first?.stackAccessToken == "test-stack-token") } @MainActor -@Test func manualHostPairingRejectsTailscaleWithFreshPairingGuidance() async throws { - let route = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "work-mac.tailnet.ts.net", port: CmxMobileDefaults.defaultHostPort) - ) +@Test func manualHostPairingRejectsMagicDNSBeforeDialing() async throws { + let responses = ScriptedTransportResponses([]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], - transportFactory: HangingTransportFactory(), - pairingRequestTimeoutNanoseconds: 1_000_000 + transportFactory: ScriptedTransportFactory(responses: responses) ) let store = CMUXMobileShellStore.preview(runtime: runtime) store.signIn() await store.connectManualHost(name: "Slow Mac", host: "work-mac.tailnet.ts.net", port: CmxMobileDefaults.defaultHostPort) - #expect(route.kind == .tailscale) #expect(store.phase == .pairing) #expect(store.connectionState == .disconnected) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") - #expect(store.connectionErrorGuidance == "Open the pairing window on your Mac and scan a fresh QR or link.") + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") + #expect(store.connectionErrorGuidance == nil) + #expect(try await responses.sentRequests().isEmpty) } @MainActor @@ -759,7 +760,7 @@ final class TerminalOutputCollector { ) store.signIn() - await store.connectManualHost(name: "Work Mac", host: "work-mac.tailnet.ts.net", port: CmxMobileDefaults.defaultHostPort) + await store.connectManualHost(name: "Work Mac", host: "100.71.210.41", port: CmxMobileDefaults.defaultHostPort) #expect(store.phase == .pairing) #expect(store.connectionState == .disconnected) @@ -1004,7 +1005,7 @@ final class TerminalOutputCollector { #expect(store.connectionState == .disconnected) #expect(store.activeTicket == nil) #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(store.connectionError == "This pairing route is not trusted. Enter the Mac's numeric Tailscale IP and port, or scan its pairing QR.") #expect(try await responses.sentRequests().isEmpty) } @@ -1836,8 +1837,11 @@ final class TerminalOutputCollector { } @MainActor -@Test func manualHostPairingRejectsTailscaleIPWithoutSendingStackToken() async throws { - let responses = ScriptedTransportResponses([]) +@Test func manualHostPairingNumericTailscaleSendsBearerOnlyAfterExactAuthorization() async throws { + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "manual-workspace", title: "Work Workspace"), + try rpcHostStatusFrame(renderGrid: false), + ]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], transportFactory: ScriptedTransportFactory(responses: responses), @@ -1848,12 +1852,11 @@ final class TerminalOutputCollector { store.signIn() await store.connectManualHost(name: "Work Mac", host: "100.71.210.41", port: CmxMobileDefaults.defaultHostPort) - #expect(store.phase == .pairing) - #expect(store.connectionState == .disconnected) - #expect(store.activeTicket == nil) - #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") - #expect(try await responses.sentRequests().isEmpty) + #expect(store.phase == .workspaces) + #expect(store.connectionState == .connected) + #expect(store.activeRoute?.kind == .tailscale) + let requests = try await responses.sentRequests() + #expect(requests.first?.stackAccessToken == "stack-token-for-tailscale-ip") } @MainActor @@ -1876,7 +1879,7 @@ final class TerminalOutputCollector { #expect(store.connectionState == .disconnected) #expect(store.activeTicket == nil) #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") #expect(try await responses.sentRequests().isEmpty) }