Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: {},
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -51,6 +52,20 @@ struct MobileConnectionMethodSection: View {
}
}

private var methodSelection: Binding<MobileConnectionMethod> {
Binding(
get: { store.method },
set: { method in
if store.request(
method,
hasAuthorizedTailscaleRoute: hasAuthorizedTailscaleRoute
) {
startPairingScanner?()
}
}
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private var footerText: String {
switch store.method {
case .automatic:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ struct MobileSettingsView: View {
if let connectionMethodStore {
MobileConnectionMethodSection(
store: connectionMethodStore,
hasAuthorizedTailscaleRoute:
store?.activeMacHasAuthorizedTailscaleRoute == true,
startPairingScanner: startPairingScanner
)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 7 additions & 5 deletions ios/cmuxUITests/cmuxUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down