diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index a7f973c834ca..29b6ceb1ad4c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2771,6 +2771,23 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + /// Whether the active saved Mac has an exact device-local Tailscale grant. + /// Settings uses this to decide whether Tailscale Only can be applied + /// immediately or must first collect a pairing code. + public var activeMacHasAuthorizedTailscaleRoute: Bool { + guard let mac = pairedMacs.first(where: \.isActive) else { return false } + return !Self.storedReconnectRoutes( + mac.routes, + supportedKinds: runtime?.supportedRouteKinds ?? [], + preferNonLoopback: Self.prefersNonLoopbackRoutes, + tailscaleRequirement: TailscaleRouteRequirement( + macDeviceID: mac.macDeviceID, + grantRoutes: mac.legacyTailscaleRoutes ?? [] + ) + ).isEmpty + } + + /// Visible store rows for identity-sensitive paths; ``pairedMacs`` is display-coalesced. private var storedPairedMacs: [MobilePairedMac] = [] /// Every scoped SQLite row, including hidden rows, for route refresh and hidden presentation. diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift index c011e432ee0d..456028111887 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift @@ -54,6 +54,22 @@ public final class MobileConnectionMethodStore { } } + /// Applies a connection-method request and reports whether Tailscale pairing + /// must be presented for the active Mac. + /// - Parameters: + /// - requestedMethod: The method selected by the user. + /// - hasAuthorizedTailscaleRoute: Whether the active Mac already has an + /// exact device-local Tailscale grant. + /// - Returns: `true` when the caller must present Tailscale pairing. + @discardableResult + public func request( + _ requestedMethod: MobileConnectionMethod, + hasAuthorizedTailscaleRoute: Bool + ) -> Bool { + method = requestedMethod + return requestedMethod == .tailscale && !hasAuthorizedTailscaleRoute + } + /// Observes connection-method changes, beginning with the current method. /// /// Each subscriber owns an independent stream. Cancelling iteration removes diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift index 741c2352cd86..f94de22d7756 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift @@ -35,4 +35,30 @@ import Testing let store = MobileConnectionMethodStore(defaults: defaults) #expect(store.method == .automatic) } + + @Test func unauthorizedTailscaleRequestPersistsAndRequiresPairing() { + let defaults = makeDefaults() + let store = MobileConnectionMethodStore(defaults: defaults) + + #expect(store.request(.tailscale, hasAuthorizedTailscaleRoute: false)) + #expect(store.method == .tailscale) + #expect(MobileConnectionMethodStore(defaults: defaults).method == .tailscale) + } + + @Test func automaticRequestReplacesTailscaleSelection() { + let store = MobileConnectionMethodStore(defaults: makeDefaults()) + #expect(store.request(.tailscale, hasAuthorizedTailscaleRoute: false)) + + #expect(!store.request(.automatic, hasAuthorizedTailscaleRoute: false)) + + #expect(store.method == .automatic) + } + + @Test func authorizedTailscaleRequestCommitsImmediately() { + let store = MobileConnectionMethodStore(defaults: makeDefaults()) + + #expect(!store.request(.tailscale, hasAuthorizedTailscaleRoute: true)) + + #expect(store.method == .tailscale) + } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 7fe4a838db78..c52b7bebc136 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -505,7 +505,12 @@ struct CMUXMobileRootView: View { isAuthenticated: isAuthenticated, connectionPhase: onboardingConnectionPhase, connectionMethod: connectionMethodStore?.method ?? .automatic, - onSelectConnectionMethod: { connectionMethodStore?.method = $0 }, + onSelectConnectionMethod: { + requestConnectionMethod( + $0, + startPairingScanner: showOnboardingPairingScanner + ) + }, onReachedConnection: markOnboardingReadyToConnect, onSkip: completeOnboarding, onRetryConnection: retryAutomaticConnection, @@ -528,7 +533,12 @@ struct CMUXMobileRootView: View { ? .fallback : .searching, connectionMethod: connectionMethodStore?.method ?? .automatic, - onSelectConnectionMethod: { connectionMethodStore?.method = $0 }, + onSelectConnectionMethod: { + requestConnectionMethod( + $0, + startPairingScanner: showOnboardingPairingScanner + ) + }, onReachedConnection: markOnboardingReadyToConnect, onSkip: completeOnboarding, onRetryConnection: {}, @@ -666,6 +676,19 @@ struct CMUXMobileRootView: View { presentAddDevice(.scanner(entry: .onboardingFallback)) } + private func requestConnectionMethod( + _ method: MobileConnectionMethod, + startPairingScanner: () -> Void + ) { + guard let connectionMethodStore else { return } + if connectionMethodStore.request( + method, + hasAuthorizedTailscaleRoute: store.activeMacHasAuthorizedTailscaleRoute + ) { + startPairingScanner() + } + } + private func presentAddDevice(_ presentation: PairingPresentation) { if isShowingAddDeviceSheet { guard pairingPresentation != presentation else { return } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionMethodSection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionMethodSection.swift index a93fb9c3b03a..b05489cfb3d2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionMethodSection.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionMethodSection.swift @@ -9,6 +9,7 @@ import SwiftUI /// each Mac's Tailscale destination. struct MobileConnectionMethodSection: View { @Bindable var store: MobileConnectionMethodStore + let hasAuthorizedTailscaleRoute: Bool let startPairingScanner: (() -> Void)? var body: some View { @@ -18,7 +19,7 @@ struct MobileConnectionMethodSection: View { "mobile.settings.connectionMethod", defaultValue: "Connection Method" ), - selection: $store.method + selection: methodSelection ) { Text(L10n.string( "mobile.settings.connectionMethod.automatic", @@ -51,6 +52,20 @@ struct MobileConnectionMethodSection: View { } } + private var methodSelection: Binding { + Binding( + get: { store.method }, + set: { method in + if store.request( + method, + hasAuthorizedTailscaleRoute: hasAuthorizedTailscaleRoute + ) { + startPairingScanner?() + } + } + ) + } + private var footerText: String { switch store.method { case .automatic: diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 100d2a88ff28..d2c23926472c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -162,6 +162,8 @@ struct MobileSettingsView: View { if let connectionMethodStore { MobileConnectionMethodSection( store: connectionMethodStore, + hasAuthorizedTailscaleRoute: + store?.activeMacHasAuthorizedTailscaleRoute == true, startPairingScanner: startPairingScanner ) } @@ -494,7 +496,7 @@ struct MobileSettingsView: View { didFinishSearch: store?.didFinishStoredMacReconnectAttempt == true ), connectionMethod: connectionMethodStore?.method ?? .automatic, - onSelectConnectionMethod: { connectionMethodStore?.method = $0 }, + onSelectConnectionMethod: { requestConnectionMethod($0) }, onReachedConnection: {}, onSkip: { showingOnboarding = false }, onRetryConnection: retryAutomaticConnection, @@ -541,6 +543,17 @@ struct MobileSettingsView: View { } } + private func requestConnectionMethod(_ method: MobileConnectionMethod) { + guard let connectionMethodStore else { return } + if connectionMethodStore.request( + method, + hasAuthorizedTailscaleRoute: store?.activeMacHasAuthorizedTailscaleRoute == true + ) { + showingOnboarding = false + startPairingScanner?() + } + } + @MainActor private func updatePhonePushEnabled(_ enabled: Bool) async -> Bool { if enabled { diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 367e71676ed0..c34d9ace6fb8 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -290,13 +290,15 @@ final class cmuxUITests: XCTestCase { XCTAssertTrue(tailscaleMethod.waitForExistence(timeout: 4)) XCTAssertTrue(tailscaleMethod.label.contains("Tailscale Only")) tap(tailscaleMethod, in: app) + // An install without a device-local Tailscale grant opens pairing, but + // the strict user choice remains selected if pairing is cancelled. + let stagedScannerPreview = element("MobilePairingScannerPreview") + XCTAssertTrue(stagedScannerPreview.waitForExistence(timeout: 4)) + app.buttons["MobileScannerCancelButton"].tap() + assertPageVisible(connectScene, timeout: 4) XCTAssertTrue(app.staticTexts["Connect over Tailscale"].waitForExistence(timeout: 4)) - XCTAssertTrue(app.staticTexts[ - "Connect only over Tailscale. Install it on both devices, join the same network, then scan the pairing code shown by cmux on your Mac." - ].waitForExistence(timeout: 4)) - // The choice is exclusive: selecting one method must deselect the other. - XCTAssertTrue(tailscaleMethod.isSelected) XCTAssertFalse(automaticMethod.isSelected) + XCTAssertTrue(tailscaleMethod.isSelected) tap(automaticMethod, in: app) XCTAssertTrue(app.staticTexts["Your Mac connects automatically"].waitForExistence(timeout: 4)) XCTAssertTrue(automaticMethod.isSelected)